diff options
Diffstat (limited to 'wp-includes/js/customize-preview-nav-menus.js')
-rw-r--r-- | wp-includes/js/customize-preview-nav-menus.js | 446 |
1 files changed, 446 insertions, 0 deletions
diff --git a/wp-includes/js/customize-preview-nav-menus.js b/wp-includes/js/customize-preview-nav-menus.js new file mode 100644 index 0000000..b5bec37 --- /dev/null +++ b/wp-includes/js/customize-preview-nav-menus.js @@ -0,0 +1,446 @@ +/** + * @output wp-includes/js/customize-preview-nav-menus.js + */ + +/* global _wpCustomizePreviewNavMenusExports */ + +/** @namespace wp.customize.navMenusPreview */ +wp.customize.navMenusPreview = wp.customize.MenusCustomizerPreview = ( function( $, _, wp, api ) { + 'use strict'; + + var self = { + data: { + navMenuInstanceArgs: {} + } + }; + if ( 'undefined' !== typeof _wpCustomizePreviewNavMenusExports ) { + _.extend( self.data, _wpCustomizePreviewNavMenusExports ); + } + + /** + * Initialize nav menus preview. + */ + self.init = function() { + var self = this, synced = false; + + /* + * Keep track of whether we synced to determine whether or not bindSettingListener + * should also initially fire the listener. This initial firing needs to wait until + * after all of the settings have been synced from the pane in order to prevent + * an infinite selective fallback-refresh. Note that this sync handler will be + * added after the sync handler in customize-preview.js, so it will be triggered + * after all of the settings are added. + */ + api.preview.bind( 'sync', function() { + synced = true; + } ); + + if ( api.selectiveRefresh ) { + // Listen for changes to settings related to nav menus. + api.each( function( setting ) { + self.bindSettingListener( setting ); + } ); + api.bind( 'add', function( setting ) { + + /* + * Handle case where an invalid nav menu item (one for which its associated object has been deleted) + * is synced from the controls into the preview. Since invalid nav menu items are filtered out from + * being exported to the frontend by the _is_valid_nav_menu_item filter in wp_get_nav_menu_items(), + * the customizer controls will have a nav_menu_item setting where the preview will have none, and + * this can trigger an infinite fallback refresh when the nav menu item lacks any valid items. + */ + if ( setting.get() && ! setting.get()._invalid ) { + self.bindSettingListener( setting, { fire: synced } ); + } + } ); + api.bind( 'remove', function( setting ) { + self.unbindSettingListener( setting ); + } ); + + /* + * Ensure that wp_nav_menu() instances nested inside of other partials + * will be recognized as being present on the page. + */ + api.selectiveRefresh.bind( 'render-partials-response', function( response ) { + if ( response.nav_menu_instance_args ) { + _.extend( self.data.navMenuInstanceArgs, response.nav_menu_instance_args ); + } + } ); + } + + api.preview.bind( 'active', function() { + self.highlightControls(); + } ); + }; + + if ( api.selectiveRefresh ) { + + /** + * Partial representing an invocation of wp_nav_menu(). + * + * @memberOf wp.customize.navMenusPreview + * @alias wp.customize.navMenusPreview.NavMenuInstancePartial + * + * @class + * @augments wp.customize.selectiveRefresh.Partial + * @since 4.5.0 + */ + self.NavMenuInstancePartial = api.selectiveRefresh.Partial.extend(/** @lends wp.customize.navMenusPreview.NavMenuInstancePartial.prototype */{ + + /** + * Constructor. + * + * @since 4.5.0 + * @param {string} id - Partial ID. + * @param {Object} options + * @param {Object} options.params + * @param {Object} options.params.navMenuArgs + * @param {string} options.params.navMenuArgs.args_hmac + * @param {string} [options.params.navMenuArgs.theme_location] + * @param {number} [options.params.navMenuArgs.menu] + * @param {Object} [options.constructingContainerContext] + */ + initialize: function( id, options ) { + var partial = this, matches, argsHmac; + matches = id.match( /^nav_menu_instance\[([0-9a-f]{32})]$/ ); + if ( ! matches ) { + throw new Error( 'Illegal id for nav_menu_instance partial. The key corresponds with the args HMAC.' ); + } + argsHmac = matches[1]; + + options = options || {}; + options.params = _.extend( + { + selector: '[data-customize-partial-id="' + id + '"]', + navMenuArgs: options.constructingContainerContext || {}, + containerInclusive: true + }, + options.params || {} + ); + api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options ); + + if ( ! _.isObject( partial.params.navMenuArgs ) ) { + throw new Error( 'Missing navMenuArgs' ); + } + if ( partial.params.navMenuArgs.args_hmac !== argsHmac ) { + throw new Error( 'args_hmac mismatch with id' ); + } + }, + + /** + * Return whether the setting is related to this partial. + * + * @since 4.5.0 + * @param {wp.customize.Value|string} setting - Object or ID. + * @param {number|Object|false|null} newValue - New value, or null if the setting was just removed. + * @param {number|Object|false|null} oldValue - Old value, or null if the setting was just added. + * @return {boolean} + */ + isRelatedSetting: function( setting, newValue, oldValue ) { + var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting, _newValue, _oldValue, urlParser; + if ( _.isString( setting ) ) { + setting = api( setting ); + } + + /* + * Prevent nav_menu_item changes only containing type_label differences triggering a refresh. + * These settings in the preview do not include type_label property, and so if one of these + * nav_menu_item settings is dirty, after a refresh the nav menu instance would do a selective + * refresh immediately because the setting from the pane would have the type_label whereas + * the setting in the preview would not, thus triggering a change event. The following + * condition short-circuits this unnecessary selective refresh and also prevents an infinite + * loop in the case where a nav_menu_instance partial had done a fallback refresh. + * @todo Nav menu item settings should not include a type_label property to begin with. + */ + isNavMenuItemSetting = /^nav_menu_item\[/.test( setting.id ); + if ( isNavMenuItemSetting && _.isObject( newValue ) && _.isObject( oldValue ) ) { + _newValue = _.clone( newValue ); + _oldValue = _.clone( oldValue ); + delete _newValue.type_label; + delete _oldValue.type_label; + + // Normalize URL scheme when parent frame is HTTPS to prevent selective refresh upon initial page load. + if ( 'https' === api.preview.scheme.get() ) { + urlParser = document.createElement( 'a' ); + urlParser.href = _newValue.url; + urlParser.protocol = 'https:'; + _newValue.url = urlParser.href; + urlParser.href = _oldValue.url; + urlParser.protocol = 'https:'; + _oldValue.url = urlParser.href; + } + + // Prevent original_title differences from causing refreshes if title is present. + if ( newValue.title ) { + delete _oldValue.original_title; + delete _newValue.original_title; + } + + if ( _.isEqual( _oldValue, _newValue ) ) { + return false; + } + } + + if ( partial.params.navMenuArgs.theme_location ) { + if ( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' === setting.id ) { + return true; + } + navMenuLocationSetting = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ); + } + + navMenuId = partial.params.navMenuArgs.menu; + if ( ! navMenuId && navMenuLocationSetting ) { + navMenuId = navMenuLocationSetting(); + } + + if ( ! navMenuId ) { + return false; + } + return ( + ( 'nav_menu[' + navMenuId + ']' === setting.id ) || + ( isNavMenuItemSetting && ( + ( newValue && newValue.nav_menu_term_id === navMenuId ) || + ( oldValue && oldValue.nav_menu_term_id === navMenuId ) + ) ) + ); + }, + + /** + * Make sure that partial fallback behavior is invoked if there is no associated menu. + * + * @since 4.5.0 + * + * @return {Promise} + */ + refresh: function() { + var partial = this, menuId, deferred = $.Deferred(); + + // Make sure the fallback behavior is invoked when the partial is no longer associated with a menu. + if ( _.isNumber( partial.params.navMenuArgs.menu ) ) { + menuId = partial.params.navMenuArgs.menu; + } else if ( partial.params.navMenuArgs.theme_location && api.has( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ) ) { + menuId = api( 'nav_menu_locations[' + partial.params.navMenuArgs.theme_location + ']' ).get(); + } + if ( ! menuId ) { + partial.fallback(); + deferred.reject(); + return deferred.promise(); + } + + return api.selectiveRefresh.Partial.prototype.refresh.call( partial ); + }, + + /** + * Render content. + * + * @inheritdoc + * @param {wp.customize.selectiveRefresh.Placement} placement + */ + renderContent: function( placement ) { + var partial = this, previousContainer = placement.container; + + // Do fallback behavior to refresh preview if menu is now empty. + if ( '' === placement.addedContent ) { + placement.partial.fallback(); + } + + if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) { + + // Trigger deprecated event. + $( document ).trigger( 'customize-preview-menu-refreshed', [ { + instanceNumber: null, // @deprecated + wpNavArgs: placement.context, // @deprecated + wpNavMenuArgs: placement.context, + oldContainer: previousContainer, + newContainer: placement.container + } ] ); + } + } + }); + + api.selectiveRefresh.partialConstructor.nav_menu_instance = self.NavMenuInstancePartial; + + /** + * Request full refresh if there are nav menu instances that lack partials which also match the supplied args. + * + * @param {Object} navMenuInstanceArgs + */ + self.handleUnplacedNavMenuInstances = function( navMenuInstanceArgs ) { + var unplacedNavMenuInstances; + unplacedNavMenuInstances = _.filter( _.values( self.data.navMenuInstanceArgs ), function( args ) { + return ! api.selectiveRefresh.partial.has( 'nav_menu_instance[' + args.args_hmac + ']' ); + } ); + if ( _.findWhere( unplacedNavMenuInstances, navMenuInstanceArgs ) ) { + api.selectiveRefresh.requestFullRefresh(); + return true; + } + return false; + }; + + /** + * Add change listener for a nav_menu[], nav_menu_item[], or nav_menu_locations[] setting. + * + * @since 4.5.0 + * + * @param {wp.customize.Value} setting + * @param {Object} [options] + * @param {boolean} options.fire Whether to invoke the callback after binding. + * This is used when a dynamic setting is added. + * @return {boolean} Whether the setting was bound. + */ + self.bindSettingListener = function( setting, options ) { + var matches; + options = options || {}; + + matches = setting.id.match( /^nav_menu\[(-?\d+)]$/ ); + if ( matches ) { + setting._navMenuId = parseInt( matches[1], 10 ); + setting.bind( this.onChangeNavMenuSetting ); + if ( options.fire ) { + this.onChangeNavMenuSetting.call( setting, setting(), false ); + } + return true; + } + + matches = setting.id.match( /^nav_menu_item\[(-?\d+)]$/ ); + if ( matches ) { + setting._navMenuItemId = parseInt( matches[1], 10 ); + setting.bind( this.onChangeNavMenuItemSetting ); + if ( options.fire ) { + this.onChangeNavMenuItemSetting.call( setting, setting(), false ); + } + return true; + } + + matches = setting.id.match( /^nav_menu_locations\[(.+?)]/ ); + if ( matches ) { + setting._navMenuThemeLocation = matches[1]; + setting.bind( this.onChangeNavMenuLocationsSetting ); + if ( options.fire ) { + this.onChangeNavMenuLocationsSetting.call( setting, setting(), false ); + } + return true; + } + + return false; + }; + + /** + * Remove change listeners for nav_menu[], nav_menu_item[], or nav_menu_locations[] setting. + * + * @since 4.5.0 + * + * @param {wp.customize.Value} setting + */ + self.unbindSettingListener = function( setting ) { + setting.unbind( this.onChangeNavMenuSetting ); + setting.unbind( this.onChangeNavMenuItemSetting ); + setting.unbind( this.onChangeNavMenuLocationsSetting ); + }; + + /** + * Handle change for nav_menu[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @this {wp.customize.Value} + */ + self.onChangeNavMenuSetting = function() { + var setting = this; + + self.handleUnplacedNavMenuInstances( { + menu: setting._navMenuId + } ); + + // Ensure all nav menu instances with a theme_location assigned to this menu are handled. + api.each( function( otherSetting ) { + if ( ! otherSetting._navMenuThemeLocation ) { + return; + } + if ( setting._navMenuId === otherSetting() ) { + self.handleUnplacedNavMenuInstances( { + theme_location: otherSetting._navMenuThemeLocation + } ); + } + } ); + }; + + /** + * Handle change for nav_menu_item[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @param {Object} newItem New value for nav_menu_item[] setting. + * @param {Object} oldItem Old value for nav_menu_item[] setting. + * @this {wp.customize.Value} + */ + self.onChangeNavMenuItemSetting = function( newItem, oldItem ) { + var item = newItem || oldItem, navMenuSetting; + navMenuSetting = api( 'nav_menu[' + String( item.nav_menu_term_id ) + ']' ); + if ( navMenuSetting ) { + self.onChangeNavMenuSetting.call( navMenuSetting ); + } + }; + + /** + * Handle change for nav_menu_locations[] setting for nav menu instances lacking partials. + * + * @since 4.5.0 + * + * @this {wp.customize.Value} + */ + self.onChangeNavMenuLocationsSetting = function() { + var setting = this, hasNavMenuInstance; + self.handleUnplacedNavMenuInstances( { + theme_location: setting._navMenuThemeLocation + } ); + + // If there are no wp_nav_menu() instances that refer to the theme location, do full refresh. + hasNavMenuInstance = !! _.findWhere( _.values( self.data.navMenuInstanceArgs ), { + theme_location: setting._navMenuThemeLocation + } ); + if ( ! hasNavMenuInstance ) { + api.selectiveRefresh.requestFullRefresh(); + } + }; + } + + /** + * Connect nav menu items with their corresponding controls in the pane. + * + * Setup shift-click on nav menu items which are more granular than the nav menu partial itself. + * Also this applies even if a nav menu is not partial-refreshable. + * + * @since 4.5.0 + */ + self.highlightControls = function() { + var selector = '.menu-item'; + + // Skip adding highlights if not in the customizer preview iframe. + if ( ! api.settings.channel ) { + return; + } + + // Focus on the menu item control when shift+clicking the menu item. + $( document ).on( 'click', selector, function( e ) { + var navMenuItemParts; + if ( ! e.shiftKey ) { + return; + } + + navMenuItemParts = $( this ).attr( 'class' ).match( /(?:^|\s)menu-item-(-?\d+)(?:\s|$)/ ); + if ( navMenuItemParts ) { + e.preventDefault(); + e.stopPropagation(); // Make sure a sub-nav menu item will get focused instead of parent items. + api.preview.send( 'focus-nav-menu-item-control', parseInt( navMenuItemParts[1], 10 ) ); + } + }); + }; + + api.bind( 'preview-ready', function() { + self.init(); + } ); + + return self; + +}( jQuery, _, wp, wp.customize ) ); |