wp/wp-includes/js/customize-preview-widgets.js
changeset 7 cf61fcea0001
parent 5 5e2f62d02dcd
child 9 177826044cd9
--- a/wp/wp-includes/js/customize-preview-widgets.js	Tue Jun 09 11:14:17 2015 +0000
+++ b/wp/wp-includes/js/customize-preview-widgets.js	Mon Oct 14 17:39:30 2019 +0200
@@ -1,129 +1,713 @@
-(function( wp, $ ){
+/* global _wpWidgetCustomizerPreviewSettings */
+
+/** @namespace wp.customize.widgetsPreview */
+wp.customize.widgetsPreview = wp.customize.WidgetCustomizerPreview = (function( $, _, wp, api ) {
+
+	var self;
 
-	if ( ! wp || ! wp.customize ) { return; }
-
-	var api = wp.customize,
-		OldPreview;
+	self = {
+		renderedSidebars: {},
+		renderedWidgets: {},
+		registeredSidebars: [],
+		registeredWidgets: {},
+		widgetSelectors: [],
+		preview: null,
+		l10n: {
+			widgetTooltip: ''
+		},
+		selectiveRefreshableWidgets: {}
+	};
 
 	/**
-	 * wp.customize.WidgetCustomizerPreview
+	 * Init widgets preview.
 	 *
+	 * @since 4.5.0
 	 */
-	api.WidgetCustomizerPreview = {
-		renderedSidebars: {}, // @todo Make rendered a property of the Backbone model
-		renderedWidgets: {}, // @todo Make rendered a property of the Backbone model
-		registeredSidebars: [], // @todo Make a Backbone collection
-		registeredWidgets: {}, // @todo Make array, Backbone collection
-		widgetSelectors: [],
-		preview: null,
-		l10n: {},
+	self.init = function() {
+		var self = this;
+
+		self.preview = api.preview;
+		if ( ! _.isEmpty( self.selectiveRefreshableWidgets ) ) {
+			self.addPartials();
+		}
+
+		self.buildWidgetSelectors();
+		self.highlightControls();
+
+		self.preview.bind( 'highlight-widget', self.highlightWidget );
+
+		api.preview.bind( 'active', function() {
+			self.highlightControls();
+		} );
+
+		/*
+		 * Refresh a partial when the controls pane requests it. This is used currently just by the
+		 * Gallery widget so that when an attachment's caption is updated in the media modal,
+		 * the widget in the preview will then be refreshed to show the change. Normally doing this
+		 * would not be necessary because all of the state should be contained inside the changeset,
+		 * as everything done in the Customizer should not make a change to the site unless the
+		 * changeset itself is published. Attachments are a current exception to this rule.
+		 * For a proposal to include attachments in the customized state, see #37887.
+		 */
+		api.preview.bind( 'refresh-widget-partial', function( widgetId ) {
+			var partialId = 'widget[' + widgetId + ']';
+			if ( api.selectiveRefresh.partial.has( partialId ) ) {
+				api.selectiveRefresh.partial( partialId ).refresh();
+			} else if ( self.renderedWidgets[ widgetId ] ) {
+				api.preview.send( 'refresh' ); // Fallback in case theme does not support 'customize-selective-refresh-widgets'.
+			}
+		} );
+	};
 
-		init: function () {
-			var self = this;
-			this.buildWidgetSelectors();
-			this.highlightControls();
+	/**
+	 * Partial representing a widget instance.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 * @alias wp.customize.widgetsPreview.WidgetPartial
+	 *
+	 * @class
+	 * @augments wp.customize.selectiveRefresh.Partial
+	 * @since 4.5.0
+	 */
+	self.WidgetPartial = api.selectiveRefresh.Partial.extend(/** @lends wp.customize.widgetsPreview.WidgetPartial.prototype */{
+
+		/**
+		 * Constructor.
+		 *
+		 * @since 4.5.0
+		 * @param {string} id - Partial ID.
+		 * @param {Object} options
+		 * @param {Object} options.params
+		 */
+		initialize: function( id, options ) {
+			var partial = this, matches;
+			matches = id.match( /^widget\[(.+)]$/ );
+			if ( ! matches ) {
+				throw new Error( 'Illegal id for widget partial.' );
+			}
 
-			this.preview.bind( 'highlight-widget', self.highlightWidget );
+			partial.widgetId = matches[1];
+			partial.widgetIdParts = self.parseWidgetId( partial.widgetId );
+			options = options || {};
+			options.params = _.extend(
+				{
+					settings: [ self.getWidgetSettingId( partial.widgetId ) ],
+					containerInclusive: true
+				},
+				options.params || {}
+			);
+
+			api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options );
+		},
+
+		/**
+		 * Refresh widget partial.
+		 *
+		 * @returns {Promise}
+		 */
+		refresh: function() {
+			var partial = this, refreshDeferred;
+			if ( ! self.selectiveRefreshableWidgets[ partial.widgetIdParts.idBase ] ) {
+				refreshDeferred = $.Deferred();
+				refreshDeferred.reject();
+				partial.fallback();
+				return refreshDeferred.promise();
+			} else {
+				return api.selectiveRefresh.Partial.prototype.refresh.call( partial );
+			}
 		},
 
 		/**
-		 * Calculate the selector for the sidebar's widgets based on the registered sidebar's info
+		 * Send widget-updated message to parent so spinner will get removed from widget control.
+		 *
+		 * @inheritdoc
+		 * @param {wp.customize.selectiveRefresh.Placement} placement
 		 */
-		buildWidgetSelectors: function () {
-			var self = this;
+		renderContent: function( placement ) {
+			var partial = this;
+			if ( api.selectiveRefresh.Partial.prototype.renderContent.call( partial, placement ) ) {
+				api.preview.send( 'widget-updated', partial.widgetId );
+				api.selectiveRefresh.trigger( 'widget-updated', partial );
+			}
+		}
+	});
+
+	/**
+	 * Partial representing a widget area.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 * @alias wp.customize.widgetsPreview.SidebarPartial
+	 *
+	 * @class
+	 * @augments wp.customize.selectiveRefresh.Partial
+	 * @since 4.5.0
+	 */
+	self.SidebarPartial = api.selectiveRefresh.Partial.extend(/** @lends wp.customize.widgetsPreview.SidebarPartial.prototype */{
+
+		/**
+		 * Constructor.
+		 *
+		 * @since 4.5.0
+		 * @param {string} id - Partial ID.
+		 * @param {Object} options
+		 * @param {Object} options.params
+		 */
+		initialize: function( id, options ) {
+			var partial = this, matches;
+			matches = id.match( /^sidebar\[(.+)]$/ );
+			if ( ! matches ) {
+				throw new Error( 'Illegal id for sidebar partial.' );
+			}
+			partial.sidebarId = matches[1];
 
-			$.each( this.registeredSidebars, function ( i, sidebar ) {
-				var widgetTpl = [
-						sidebar.before_widget.replace('%1$s', '').replace('%2$s', ''),
-						sidebar.before_title,
-						sidebar.after_title,
-						sidebar.after_widget
-					].join(''),
-					emptyWidget,
-					widgetSelector,
-					widgetClasses;
+			options = options || {};
+			options.params = _.extend(
+				{
+					settings: [ 'sidebars_widgets[' + partial.sidebarId + ']' ]
+				},
+				options.params || {}
+			);
+
+			api.selectiveRefresh.Partial.prototype.initialize.call( partial, id, options );
+
+			if ( ! partial.params.sidebarArgs ) {
+				throw new Error( 'The sidebarArgs param was not provided.' );
+			}
+			if ( partial.params.settings.length > 1 ) {
+				throw new Error( 'Expected SidebarPartial to only have one associated setting' );
+			}
+		},
 
-				emptyWidget = $(widgetTpl);
-				widgetSelector = emptyWidget.prop('tagName');
-				widgetClasses = emptyWidget.prop('className');
+		/**
+		 * Set up the partial.
+		 *
+		 * @since 4.5.0
+		 */
+		ready: function() {
+			var sidebarPartial = this;
+
+			// Watch for changes to the sidebar_widgets setting.
+			_.each( sidebarPartial.settings(), function( settingId ) {
+				api( settingId ).bind( _.bind( sidebarPartial.handleSettingChange, sidebarPartial ) );
+			} );
 
-				// Prevent a rare case when before_widget, before_title, after_title and after_widget is empty.
-				if ( ! widgetClasses ) {
+			// Trigger an event for this sidebar being updated whenever a widget inside is rendered.
+			api.selectiveRefresh.bind( 'partial-content-rendered', function( placement ) {
+				var isAssignedWidgetPartial = (
+					placement.partial.extended( self.WidgetPartial ) &&
+					( -1 !== _.indexOf( sidebarPartial.getWidgetIds(), placement.partial.widgetId ) )
+				);
+				if ( isAssignedWidgetPartial ) {
+					api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial );
+				}
+			} );
+
+			// Make sure that a widget partial has a container in the DOM prior to a refresh.
+			api.bind( 'change', function( widgetSetting ) {
+				var widgetId, parsedId;
+				parsedId = self.parseWidgetSettingId( widgetSetting.id );
+				if ( ! parsedId ) {
 					return;
 				}
+				widgetId = parsedId.idBase;
+				if ( parsedId.number ) {
+					widgetId += '-' + String( parsedId.number );
+				}
+				if ( -1 !== _.indexOf( sidebarPartial.getWidgetIds(), widgetId ) ) {
+					sidebarPartial.ensureWidgetPlacementContainers( widgetId );
+				}
+			} );
+		},
 
-				widgetClasses = widgetClasses.replace(/^\s+|\s+$/g, '');
+		/**
+		 * Get the before/after boundary nodes for all instances of this sidebar (usually one).
+		 *
+		 * Note that TreeWalker is not implemented in IE8.
+		 *
+		 * @since 4.5.0
+		 * @returns {Array.<{before: Comment, after: Comment, instanceNumber: number}>}
+		 */
+		findDynamicSidebarBoundaryNodes: function() {
+			var partial = this, regExp, boundaryNodes = {}, recursiveCommentTraversal;
+			regExp = /^(dynamic_sidebar_before|dynamic_sidebar_after):(.+):(\d+)$/;
+			recursiveCommentTraversal = function( childNodes ) {
+				_.each( childNodes, function( node ) {
+					var matches;
+					if ( 8 === node.nodeType ) {
+						matches = node.nodeValue.match( regExp );
+						if ( ! matches || matches[2] !== partial.sidebarId ) {
+							return;
+						}
+						if ( _.isUndefined( boundaryNodes[ matches[3] ] ) ) {
+							boundaryNodes[ matches[3] ] = {
+								before: null,
+								after: null,
+								instanceNumber: parseInt( matches[3], 10 )
+							};
+						}
+						if ( 'dynamic_sidebar_before' === matches[1] ) {
+							boundaryNodes[ matches[3] ].before = node;
+						} else {
+							boundaryNodes[ matches[3] ].after = node;
+						}
+					} else if ( 1 === node.nodeType ) {
+						recursiveCommentTraversal( node.childNodes );
+					}
+				} );
+			};
 
-				if ( widgetClasses ) {
-					widgetSelector += '.' + widgetClasses.split(/\s+/).join('.');
-				}
-				self.widgetSelectors.push(widgetSelector);
-			});
+			recursiveCommentTraversal( document.body.childNodes );
+			return _.values( boundaryNodes );
+		},
+
+		/**
+		 * Get the placements for this partial.
+		 *
+		 * @since 4.5.0
+		 * @returns {Array}
+		 */
+		placements: function() {
+			var partial = this;
+			return _.map( partial.findDynamicSidebarBoundaryNodes(), function( boundaryNodes ) {
+				return new api.selectiveRefresh.Placement( {
+					partial: partial,
+					container: null,
+					startNode: boundaryNodes.before,
+					endNode: boundaryNodes.after,
+					context: {
+						instanceNumber: boundaryNodes.instanceNumber
+					}
+				} );
+			} );
+		},
+
+		/**
+		 * Get the list of widget IDs associated with this widget area.
+		 *
+		 * @since 4.5.0
+		 *
+		 * @returns {Array}
+		 */
+		getWidgetIds: function() {
+			var sidebarPartial = this, settingId, widgetIds;
+			settingId = sidebarPartial.settings()[0];
+			if ( ! settingId ) {
+				throw new Error( 'Missing associated setting.' );
+			}
+			if ( ! api.has( settingId ) ) {
+				throw new Error( 'Setting does not exist.' );
+			}
+			widgetIds = api( settingId ).get();
+			if ( ! _.isArray( widgetIds ) ) {
+				throw new Error( 'Expected setting to be array of widget IDs' );
+			}
+			return widgetIds.slice( 0 );
 		},
 
 		/**
-		 * Highlight the widget on widget updates or widget control mouse overs.
+		 * Reflow widgets in the sidebar, ensuring they have the proper position in the DOM.
+		 *
+		 * @since 4.5.0
 		 *
-		 * @param  {string} widgetId ID of the widget.
+		 * @return {Array.<wp.customize.selectiveRefresh.Placement>} List of placements that were reflowed.
+		 */
+		reflowWidgets: function() {
+			var sidebarPartial = this, sidebarPlacements, widgetIds, widgetPartials, sortedSidebarContainers = [];
+			widgetIds = sidebarPartial.getWidgetIds();
+			sidebarPlacements = sidebarPartial.placements();
+
+			widgetPartials = {};
+			_.each( widgetIds, function( widgetId ) {
+				var widgetPartial = api.selectiveRefresh.partial( 'widget[' + widgetId + ']' );
+				if ( widgetPartial ) {
+					widgetPartials[ widgetId ] = widgetPartial;
+				}
+			} );
+
+			_.each( sidebarPlacements, function( sidebarPlacement ) {
+				var sidebarWidgets = [], needsSort = false, thisPosition, lastPosition = -1;
+
+				// Gather list of widget partial containers in this sidebar, and determine if a sort is needed.
+				_.each( widgetPartials, function( widgetPartial ) {
+					_.each( widgetPartial.placements(), function( widgetPlacement ) {
+
+						if ( sidebarPlacement.context.instanceNumber === widgetPlacement.context.sidebar_instance_number ) {
+							thisPosition = widgetPlacement.container.index();
+							sidebarWidgets.push( {
+								partial: widgetPartial,
+								placement: widgetPlacement,
+								position: thisPosition
+							} );
+							if ( thisPosition < lastPosition ) {
+								needsSort = true;
+							}
+							lastPosition = thisPosition;
+						}
+					} );
+				} );
+
+				if ( needsSort ) {
+					_.each( sidebarWidgets, function( sidebarWidget ) {
+						sidebarPlacement.endNode.parentNode.insertBefore(
+							sidebarWidget.placement.container[0],
+							sidebarPlacement.endNode
+						);
+
+						// @todo Rename partial-placement-moved?
+						api.selectiveRefresh.trigger( 'partial-content-moved', sidebarWidget.placement );
+					} );
+
+					sortedSidebarContainers.push( sidebarPlacement );
+				}
+			} );
+
+			if ( sortedSidebarContainers.length > 0 ) {
+				api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial );
+			}
+
+			return sortedSidebarContainers;
+		},
+
+		/**
+		 * Make sure there is a widget instance container in this sidebar for the given widget ID.
+		 *
+		 * @since 4.5.0
+		 *
+		 * @param {string} widgetId
+		 * @returns {wp.customize.selectiveRefresh.Partial} Widget instance partial.
 		 */
-		highlightWidget: function( widgetId ) {
-			var $body = $( document.body ),
-				$widget = $( '#' + widgetId );
+		ensureWidgetPlacementContainers: function( widgetId ) {
+			var sidebarPartial = this, widgetPartial, wasInserted = false, partialId = 'widget[' + widgetId + ']';
+			widgetPartial = api.selectiveRefresh.partial( partialId );
+			if ( ! widgetPartial ) {
+				widgetPartial = new self.WidgetPartial( partialId, {
+					params: {}
+				} );
+			}
+
+			// Make sure that there is a container element for the widget in the sidebar, if at least a placeholder.
+			_.each( sidebarPartial.placements(), function( sidebarPlacement ) {
+				var foundWidgetPlacement, widgetContainerElement;
+
+				foundWidgetPlacement = _.find( widgetPartial.placements(), function( widgetPlacement ) {
+					return ( widgetPlacement.context.sidebar_instance_number === sidebarPlacement.context.instanceNumber );
+				} );
+				if ( foundWidgetPlacement ) {
+					return;
+				}
+
+				widgetContainerElement = $(
+					sidebarPartial.params.sidebarArgs.before_widget.replace( /%1\$s/g, widgetId ).replace( /%2\$s/g, 'widget' ) +
+					sidebarPartial.params.sidebarArgs.after_widget
+				);
+
+				// Handle rare case where before_widget and after_widget are empty.
+				if ( ! widgetContainerElement[0] ) {
+					return;
+				}
+
+				widgetContainerElement.attr( 'data-customize-partial-id', widgetPartial.id );
+				widgetContainerElement.attr( 'data-customize-partial-type', 'widget' );
+				widgetContainerElement.attr( 'data-customize-widget-id', widgetId );
+
+				/*
+				 * Make sure the widget container element has the customize-container context data.
+				 * The sidebar_instance_number is used to disambiguate multiple instances of the
+				 * same sidebar are rendered onto the template, and so the same widget is embedded
+				 * multiple times.
+				 */
+				widgetContainerElement.data( 'customize-partial-placement-context', {
+					'sidebar_id': sidebarPartial.sidebarId,
+					'sidebar_instance_number': sidebarPlacement.context.instanceNumber
+				} );
+
+				sidebarPlacement.endNode.parentNode.insertBefore( widgetContainerElement[0], sidebarPlacement.endNode );
+				wasInserted = true;
+			} );
+
+			api.selectiveRefresh.partial.add( widgetPartial );
+
+			if ( wasInserted ) {
+				sidebarPartial.reflowWidgets();
+			}
 
-			$body.find( '.widget-customizer-highlighted-widget' ).removeClass( 'widget-customizer-highlighted-widget' );
+			return widgetPartial;
+		},
+
+		/**
+		 * Handle change to the sidebars_widgets[] setting.
+		 *
+		 * @since 4.5.0
+		 *
+		 * @param {Array} newWidgetIds New widget ids.
+		 * @param {Array} oldWidgetIds Old widget ids.
+		 */
+		handleSettingChange: function( newWidgetIds, oldWidgetIds ) {
+			var sidebarPartial = this, needsRefresh, widgetsRemoved, widgetsAdded, addedWidgetPartials = [];
+
+			needsRefresh = (
+				( oldWidgetIds.length > 0 && 0 === newWidgetIds.length ) ||
+				( newWidgetIds.length > 0 && 0 === oldWidgetIds.length )
+			);
+			if ( needsRefresh ) {
+				sidebarPartial.fallback();
+				return;
+			}
 
-			$widget.addClass( 'widget-customizer-highlighted-widget' );
-			setTimeout( function () {
-				$widget.removeClass( 'widget-customizer-highlighted-widget' );
-			}, 500 );
+			// Handle removal of widgets.
+			widgetsRemoved = _.difference( oldWidgetIds, newWidgetIds );
+			_.each( widgetsRemoved, function( removedWidgetId ) {
+				var widgetPartial = api.selectiveRefresh.partial( 'widget[' + removedWidgetId + ']' );
+				if ( widgetPartial ) {
+					_.each( widgetPartial.placements(), function( placement ) {
+						var isRemoved = (
+							placement.context.sidebar_id === sidebarPartial.sidebarId ||
+							( placement.context.sidebar_args && placement.context.sidebar_args.id === sidebarPartial.sidebarId )
+						);
+						if ( isRemoved ) {
+							placement.container.remove();
+						}
+					} );
+				}
+				delete self.renderedWidgets[ removedWidgetId ];
+			} );
+
+			// Handle insertion of widgets.
+			widgetsAdded = _.difference( newWidgetIds, oldWidgetIds );
+			_.each( widgetsAdded, function( addedWidgetId ) {
+				var widgetPartial = sidebarPartial.ensureWidgetPlacementContainers( addedWidgetId );
+				addedWidgetPartials.push( widgetPartial );
+				self.renderedWidgets[ addedWidgetId ] = true;
+			} );
+
+			_.each( addedWidgetPartials, function( widgetPartial ) {
+				widgetPartial.refresh();
+			} );
+
+			api.selectiveRefresh.trigger( 'sidebar-updated', sidebarPartial );
 		},
 
 		/**
-		 * Show a title and highlight widgets on hover. On shift+clicking
-		 * focus the widget control.
+		 * Note that the meat is handled in handleSettingChange because it has the context of which widgets were removed.
+		 *
+		 * @since 4.5.0
 		 */
-		highlightControls: function() {
-			var self = this,
-				selector = this.widgetSelectors.join(',');
+		refresh: function() {
+			var partial = this, deferred = $.Deferred();
+
+			deferred.fail( function() {
+				partial.fallback();
+			} );
+
+			if ( 0 === partial.placements().length ) {
+				deferred.reject();
+			} else {
+				_.each( partial.reflowWidgets(), function( sidebarPlacement ) {
+					api.selectiveRefresh.trigger( 'partial-content-rendered', sidebarPlacement );
+				} );
+				deferred.resolve();
+			}
+
+			return deferred.promise();
+		}
+	});
 
-			$(selector).attr( 'title', this.l10n.widgetTooltip );
+	api.selectiveRefresh.partialConstructor.sidebar = self.SidebarPartial;
+	api.selectiveRefresh.partialConstructor.widget = self.WidgetPartial;
+
+	/**
+	 * Add partials for the registered widget areas (sidebars).
+	 *
+	 * @since 4.5.0
+	 */
+	self.addPartials = function() {
+		_.each( self.registeredSidebars, function( registeredSidebar ) {
+			var partial, partialId = 'sidebar[' + registeredSidebar.id + ']';
+			partial = api.selectiveRefresh.partial( partialId );
+			if ( ! partial ) {
+				partial = new self.SidebarPartial( partialId, {
+					params: {
+						sidebarArgs: registeredSidebar
+					}
+				} );
+				api.selectiveRefresh.partial.add( partial );
+			}
+		} );
+	};
 
-			$(document).on( 'mouseenter', selector, function () {
-				self.preview.send( 'highlight-widget-control', $( this ).prop( 'id' ) );
-			});
+	/**
+	 * Calculate the selector for the sidebar's widgets based on the registered sidebar's info.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 3.9.0
+	 */
+	self.buildWidgetSelectors = function() {
+		var self = this;
+
+		$.each( self.registeredSidebars, function( i, sidebar ) {
+			var widgetTpl = [
+					sidebar.before_widget,
+					sidebar.before_title,
+					sidebar.after_title,
+					sidebar.after_widget
+				].join( '' ),
+				emptyWidget,
+				widgetSelector,
+				widgetClasses;
+
+			emptyWidget = $( widgetTpl );
+			widgetSelector = emptyWidget.prop( 'tagName' ) || '';
+			widgetClasses = emptyWidget.prop( 'className' ) || '';
 
-			// Open expand the widget control when shift+clicking the widget element
-			$(document).on( 'click', selector, function ( e ) {
-				if ( ! e.shiftKey ) {
-					return;
-				}
-				e.preventDefault();
+			// Prevent a rare case when before_widget, before_title, after_title and after_widget is empty.
+			if ( ! widgetClasses ) {
+				return;
+			}
+
+			// Remove class names that incorporate the string formatting placeholders %1$s and %2$s.
+			widgetClasses = widgetClasses.replace( /\S*%[12]\$s\S*/g, '' );
+			widgetClasses = widgetClasses.replace( /^\s+|\s+$/g, '' );
+			if ( widgetClasses ) {
+				widgetSelector += '.' + widgetClasses.split( /\s+/ ).join( '.' );
+			}
+			self.widgetSelectors.push( widgetSelector );
+		});
+	};
 
-				self.preview.send( 'focus-widget-control', $( this ).prop( 'id' ) );
-			});
-		}
+	/**
+	 * Highlight the widget on widget updates or widget control mouse overs.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 3.9.0
+	 * @param  {string} widgetId ID of the widget.
+	 */
+	self.highlightWidget = function( widgetId ) {
+		var $body = $( document.body ),
+			$widget = $( '#' + widgetId );
+
+		$body.find( '.widget-customizer-highlighted-widget' ).removeClass( 'widget-customizer-highlighted-widget' );
+
+		$widget.addClass( 'widget-customizer-highlighted-widget' );
+		setTimeout( function() {
+			$widget.removeClass( 'widget-customizer-highlighted-widget' );
+		}, 500 );
 	};
 
 	/**
-	 * Capture the instance of the Preview since it is private
+	 * Show a title and highlight widgets on hover. On shift+clicking
+	 * focus the widget control.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 3.9.0
 	 */
-	OldPreview = api.Preview;
-	api.Preview = OldPreview.extend( {
-		initialize: function( params, options ) {
-			api.WidgetCustomizerPreview.preview = this;
-			OldPreview.prototype.initialize.call( this, params, options );
-		}
-	} );
+	self.highlightControls = function() {
+		var self = this,
+			selector = this.widgetSelectors.join( ',' );
 
-	$(function () {
-		var settings = window._wpWidgetCustomizerPreviewSettings;
-		if ( ! settings ) {
+		// Skip adding highlights if not in the customizer preview iframe.
+		if ( ! api.settings.channel ) {
 			return;
 		}
 
-		$.extend( api.WidgetCustomizerPreview, settings );
+		$( selector ).attr( 'title', this.l10n.widgetTooltip );
+
+		$( document ).on( 'mouseenter', selector, function() {
+			self.preview.send( 'highlight-widget-control', $( this ).prop( 'id' ) );
+		});
+
+		// Open expand the widget control when shift+clicking the widget element
+		$( document ).on( 'click', selector, function( e ) {
+			if ( ! e.shiftKey ) {
+				return;
+			}
+			e.preventDefault();
+
+			self.preview.send( 'focus-widget-control', $( this ).prop( 'id' ) );
+		});
+	};
+
+	/**
+	 * Parse a widget ID.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 4.5.0
+	 *
+	 * @param {string} widgetId Widget ID.
+	 * @returns {{idBase: string, number: number|null}}
+	 */
+	self.parseWidgetId = function( widgetId ) {
+		var matches, parsed = {
+			idBase: '',
+			number: null
+		};
+
+		matches = widgetId.match( /^(.+)-(\d+)$/ );
+		if ( matches ) {
+			parsed.idBase = matches[1];
+			parsed.number = parseInt( matches[2], 10 );
+		} else {
+			parsed.idBase = widgetId; // Likely an old single widget.
+		}
+
+		return parsed;
+	};
 
-		api.WidgetCustomizerPreview.init();
+	/**
+	 * Parse a widget setting ID.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 4.5.0
+	 *
+	 * @param {string} settingId Widget setting ID.
+	 * @returns {{idBase: string, number: number|null}|null}
+	 */
+	self.parseWidgetSettingId = function( settingId ) {
+		var matches, parsed = {
+			idBase: '',
+			number: null
+		};
+
+		matches = settingId.match( /^widget_([^\[]+?)(?:\[(\d+)])?$/ );
+		if ( ! matches ) {
+			return null;
+		}
+		parsed.idBase = matches[1];
+		if ( matches[2] ) {
+			parsed.number = parseInt( matches[2], 10 );
+		}
+		return parsed;
+	};
+
+	/**
+	 * Convert a widget ID into a Customizer setting ID.
+	 *
+	 * @memberOf wp.customize.widgetsPreview
+	 *
+	 * @since 4.5.0
+	 *
+	 * @param {string} widgetId Widget ID.
+	 * @returns {string} settingId Setting ID.
+	 */
+	self.getWidgetSettingId = function( widgetId ) {
+		var parsed = this.parseWidgetId( widgetId ), settingId;
+
+		settingId = 'widget_' + parsed.idBase;
+		if ( parsed.number ) {
+			settingId += '[' + String( parsed.number ) + ']';
+		}
+
+		return settingId;
+	};
+
+	api.bind( 'preview-ready', function() {
+		$.extend( self, _wpWidgetCustomizerPreviewSettings );
+		self.init();
 	});
 
-})( window.wp, jQuery );
+	return self;
+})( jQuery, _, wp, wp.customize );