wp/wp-admin/js/widgets/media-widgets.js
changeset 7 cf61fcea0001
child 9 177826044cd9
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/wp/wp-admin/js/widgets/media-widgets.js	Mon Oct 14 17:39:30 2019 +0200
@@ -0,0 +1,1306 @@
+/* eslint consistent-this: [ "error", "control" ] */
+wp.mediaWidgets = ( function( $ ) {
+	'use strict';
+
+	var component = {};
+
+	/**
+	 * Widget control (view) constructors, mapping widget id_base to subclass of MediaWidgetControl.
+	 *
+	 * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base.
+	 *
+	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetModel>}
+	 */
+	component.controlConstructors = {};
+
+	/**
+	 * Widget model constructors, mapping widget id_base to subclass of MediaWidgetModel.
+	 *
+	 * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base.
+	 *
+	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetModel>}
+	 */
+	component.modelConstructors = {};
+
+	/**
+	 * Library which persists the customized display settings across selections.
+	 *
+	 * @class PersistentDisplaySettingsLibrary
+	 * @constructor
+	 */
+	component.PersistentDisplaySettingsLibrary = wp.media.controller.Library.extend({
+
+		/**
+		 * Initialize.
+		 *
+		 * @param {Object} options - Options.
+		 * @returns {void}
+		 */
+		initialize: function initialize( options ) {
+			_.bindAll( this, 'handleDisplaySettingChange' );
+			wp.media.controller.Library.prototype.initialize.call( this, options );
+		},
+
+		/**
+		 * Sync changes to the current display settings back into the current customized.
+		 *
+		 * @param {Backbone.Model} displaySettings - Modified display settings.
+		 * @returns {void}
+		 */
+		handleDisplaySettingChange: function handleDisplaySettingChange( displaySettings ) {
+			this.get( 'selectedDisplaySettings' ).set( displaySettings.attributes );
+		},
+
+		/**
+		 * Get the display settings model.
+		 *
+		 * Model returned is updated with the current customized display settings,
+		 * and an event listener is added so that changes made to the settings
+		 * will sync back into the model storing the session's customized display
+		 * settings.
+		 *
+		 * @param {Backbone.Model} model - Display settings model.
+		 * @returns {Backbone.Model} Display settings model.
+		 */
+		display: function getDisplaySettingsModel( model ) {
+			var display, selectedDisplaySettings = this.get( 'selectedDisplaySettings' );
+			display = wp.media.controller.Library.prototype.display.call( this, model );
+
+			display.off( 'change', this.handleDisplaySettingChange ); // Prevent duplicated event handlers.
+			display.set( selectedDisplaySettings.attributes );
+			if ( 'custom' === selectedDisplaySettings.get( 'link_type' ) ) {
+				display.linkUrl = selectedDisplaySettings.get( 'link_url' );
+			}
+			display.on( 'change', this.handleDisplaySettingChange );
+			return display;
+		}
+	});
+
+	/**
+	 * Extended view for managing the embed UI.
+	 *
+	 * @class MediaEmbedView
+	 * @constructor
+	 */
+	component.MediaEmbedView = wp.media.view.Embed.extend({
+
+		/**
+		 * Initialize.
+		 *
+		 * @since 4.9.0
+		 *
+		 * @param {object} options - Options.
+		 * @returns {void}
+		 */
+		initialize: function( options ) {
+			var view = this, embedController; // eslint-disable-line consistent-this
+			wp.media.view.Embed.prototype.initialize.call( view, options );
+			if ( 'image' !== view.controller.options.mimeType ) {
+				embedController = view.controller.states.get( 'embed' );
+				embedController.off( 'scan', embedController.scanImage, embedController );
+			}
+		},
+
+		/**
+		 * Refresh embed view.
+		 *
+		 * Forked override of {wp.media.view.Embed#refresh()} to suppress irrelevant "link text" field.
+		 *
+		 * @returns {void}
+		 */
+		refresh: function refresh() {
+			var Constructor;
+
+			if ( 'image' === this.controller.options.mimeType ) {
+				Constructor = wp.media.view.EmbedImage;
+			} else {
+
+				// This should be eliminated once #40450 lands of when this is merged into core.
+				Constructor = wp.media.view.EmbedLink.extend({
+
+					/**
+					 * Set the disabled state on the Add to Widget button.
+					 *
+					 * @param {boolean} disabled - Disabled.
+					 * @returns {void}
+					 */
+					setAddToWidgetButtonDisabled: function setAddToWidgetButtonDisabled( disabled ) {
+						this.views.parent.views.parent.views.get( '.media-frame-toolbar' )[0].$el.find( '.media-button-select' ).prop( 'disabled', disabled );
+					},
+
+					/**
+					 * Set or clear an error notice.
+					 *
+					 * @param {string} notice - Notice.
+					 * @returns {void}
+					 */
+					setErrorNotice: function setErrorNotice( notice ) {
+						var embedLinkView = this, noticeContainer; // eslint-disable-line consistent-this
+
+						noticeContainer = embedLinkView.views.parent.$el.find( '> .notice:first-child' );
+						if ( ! notice ) {
+							if ( noticeContainer.length ) {
+								noticeContainer.slideUp( 'fast' );
+							}
+						} else {
+							if ( ! noticeContainer.length ) {
+								noticeContainer = $( '<div class="media-widget-embed-notice notice notice-error notice-alt"></div>' );
+								noticeContainer.hide();
+								embedLinkView.views.parent.$el.prepend( noticeContainer );
+							}
+							noticeContainer.empty();
+							noticeContainer.append( $( '<p>', {
+								html: notice
+							}));
+							noticeContainer.slideDown( 'fast' );
+						}
+					},
+
+					/**
+					 * Update oEmbed.
+					 *
+					 * @since 4.9.0
+					 *
+					 * @returns {void}
+					 */
+					updateoEmbed: function() {
+						var embedLinkView = this, url; // eslint-disable-line consistent-this
+
+						url = embedLinkView.model.get( 'url' );
+
+						// Abort if the URL field was emptied out.
+						if ( ! url ) {
+							embedLinkView.setErrorNotice( '' );
+							embedLinkView.setAddToWidgetButtonDisabled( true );
+							return;
+						}
+
+						if ( ! url.match( /^(http|https):\/\/.+\// ) ) {
+							embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' );
+							embedLinkView.setAddToWidgetButtonDisabled( true );
+						}
+
+						wp.media.view.EmbedLink.prototype.updateoEmbed.call( embedLinkView );
+					},
+
+					/**
+					 * Fetch media.
+					 *
+					 * @returns {void}
+					 */
+					fetch: function() {
+						var embedLinkView = this, fetchSuccess, matches, fileExt, urlParser, url, re, youTubeEmbedMatch; // eslint-disable-line consistent-this
+						url = embedLinkView.model.get( 'url' );
+
+						if ( embedLinkView.dfd && 'pending' === embedLinkView.dfd.state() ) {
+							embedLinkView.dfd.abort();
+						}
+
+						fetchSuccess = function( response ) {
+							embedLinkView.renderoEmbed({
+								data: {
+									body: response
+								}
+							});
+
+							embedLinkView.controller.$el.find( '#embed-url-field' ).removeClass( 'invalid' );
+							embedLinkView.setErrorNotice( '' );
+							embedLinkView.setAddToWidgetButtonDisabled( false );
+						};
+
+						urlParser = document.createElement( 'a' );
+						urlParser.href = url;
+						matches = urlParser.pathname.toLowerCase().match( /\.(\w+)$/ );
+						if ( matches ) {
+							fileExt = matches[1];
+							if ( ! wp.media.view.settings.embedMimes[ fileExt ] ) {
+								embedLinkView.renderFail();
+							} else if ( 0 !== wp.media.view.settings.embedMimes[ fileExt ].indexOf( embedLinkView.controller.options.mimeType ) ) {
+								embedLinkView.renderFail();
+							} else {
+								fetchSuccess( '<!--success-->' );
+							}
+							return;
+						}
+
+						// Support YouTube embed links.
+						re = /https?:\/\/www\.youtube\.com\/embed\/([^/]+)/;
+						youTubeEmbedMatch = re.exec( url );
+						if ( youTubeEmbedMatch ) {
+							url = 'https://www.youtube.com/watch?v=' + youTubeEmbedMatch[ 1 ];
+							// silently change url to proper oembed-able version.
+							embedLinkView.model.attributes.url = url;
+						}
+
+						embedLinkView.dfd = wp.apiRequest({
+							url: wp.media.view.settings.oEmbedProxyUrl,
+							data: {
+								url: url,
+								maxwidth: embedLinkView.model.get( 'width' ),
+								maxheight: embedLinkView.model.get( 'height' ),
+								discover: false
+							},
+							type: 'GET',
+							dataType: 'json',
+							context: embedLinkView
+						});
+
+						embedLinkView.dfd.done( function( response ) {
+							if ( embedLinkView.controller.options.mimeType !== response.type ) {
+								embedLinkView.renderFail();
+								return;
+							}
+							fetchSuccess( response.html );
+						});
+						embedLinkView.dfd.fail( _.bind( embedLinkView.renderFail, embedLinkView ) );
+					},
+
+					/**
+					 * Handle render failure.
+					 *
+					 * Overrides the {EmbedLink#renderFail()} method to prevent showing the "Link Text" field.
+					 * The element is getting display:none in the stylesheet, but the underlying method uses
+					 * uses {jQuery.fn.show()} which adds an inline style. This avoids the need for !important.
+					 *
+					 * @returns {void}
+					 */
+					renderFail: function renderFail() {
+						var embedLinkView = this; // eslint-disable-line consistent-this
+						embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' );
+						embedLinkView.setErrorNotice( embedLinkView.controller.options.invalidEmbedTypeError || 'ERROR' );
+						embedLinkView.setAddToWidgetButtonDisabled( true );
+					}
+				});
+			}
+
+			this.settings( new Constructor({
+				controller: this.controller,
+				model:      this.model.props,
+				priority:   40
+			}));
+		}
+	});
+
+	/**
+	 * Custom media frame for selecting uploaded media or providing media by URL.
+	 *
+	 * @class MediaFrameSelect
+	 * @constructor
+	 */
+	component.MediaFrameSelect = wp.media.view.MediaFrame.Post.extend({
+
+		/**
+		 * Create the default states.
+		 *
+		 * @returns {void}
+		 */
+		createStates: function createStates() {
+			var mime = this.options.mimeType, specificMimes = [];
+			_.each( wp.media.view.settings.embedMimes, function( embedMime ) {
+				if ( 0 === embedMime.indexOf( mime ) ) {
+					specificMimes.push( embedMime );
+				}
+			});
+			if ( specificMimes.length > 0 ) {
+				mime = specificMimes;
+			}
+
+			this.states.add([
+
+				// Main states.
+				new component.PersistentDisplaySettingsLibrary({
+					id:         'insert',
+					title:      this.options.title,
+					selection:  this.options.selection,
+					priority:   20,
+					toolbar:    'main-insert',
+					filterable: 'dates',
+					library:    wp.media.query({
+						type: mime
+					}),
+					multiple:   false,
+					editable:   true,
+
+					selectedDisplaySettings: this.options.selectedDisplaySettings,
+					displaySettings: _.isUndefined( this.options.showDisplaySettings ) ? true : this.options.showDisplaySettings,
+					displayUserSettings: false // We use the display settings from the current/default widget instance props.
+				}),
+
+				new wp.media.controller.EditImage({ model: this.options.editImage }),
+
+				// Embed states.
+				new wp.media.controller.Embed({
+					metadata: this.options.metadata,
+					type: 'image' === this.options.mimeType ? 'image' : 'link',
+					invalidEmbedTypeError: this.options.invalidEmbedTypeError
+				})
+			]);
+		},
+
+		/**
+		 * Main insert toolbar.
+		 *
+		 * Forked override of {wp.media.view.MediaFrame.Post#mainInsertToolbar()} to override text.
+		 *
+		 * @param {wp.Backbone.View} view - Toolbar view.
+		 * @this {wp.media.controller.Library}
+		 * @returns {void}
+		 */
+		mainInsertToolbar: function mainInsertToolbar( view ) {
+			var controller = this; // eslint-disable-line consistent-this
+			view.set( 'insert', {
+				style:    'primary',
+				priority: 80,
+				text:     controller.options.text, // The whole reason for the fork.
+				requires: { selection: true },
+
+				/**
+				 * Handle click.
+				 *
+				 * @fires wp.media.controller.State#insert()
+				 * @returns {void}
+				 */
+				click: function onClick() {
+					var state = controller.state(),
+						selection = state.get( 'selection' );
+
+					controller.close();
+					state.trigger( 'insert', selection ).reset();
+				}
+			});
+		},
+
+		/**
+		 * Main embed toolbar.
+		 *
+		 * Forked override of {wp.media.view.MediaFrame.Post#mainEmbedToolbar()} to override text.
+		 *
+		 * @param {wp.Backbone.View} toolbar - Toolbar view.
+		 * @this {wp.media.controller.Library}
+		 * @returns {void}
+		 */
+		mainEmbedToolbar: function mainEmbedToolbar( toolbar ) {
+			toolbar.view = new wp.media.view.Toolbar.Embed({
+				controller: this,
+				text: this.options.text,
+				event: 'insert'
+			});
+		},
+
+		/**
+		 * Embed content.
+		 *
+		 * Forked override of {wp.media.view.MediaFrame.Post#embedContent()} to suppress irrelevant "link text" field.
+		 *
+		 * @returns {void}
+		 */
+		embedContent: function embedContent() {
+			var view = new component.MediaEmbedView({
+				controller: this,
+				model:      this.state()
+			}).render();
+
+			this.content.set( view );
+
+			if ( ! wp.media.isTouchDevice ) {
+				view.url.focus();
+			}
+		}
+	});
+
+	/**
+	 * Media widget control.
+	 *
+	 * @class MediaWidgetControl
+	 * @constructor
+	 * @abstract
+	 */
+	component.MediaWidgetControl = Backbone.View.extend({
+
+		/**
+		 * Translation strings.
+		 *
+		 * The mapping of translation strings is handled by media widget subclasses,
+		 * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
+		 *
+		 * @type {Object}
+		 */
+		l10n: {
+			add_to_widget: '{{add_to_widget}}',
+			add_media: '{{add_media}}'
+		},
+
+		/**
+		 * Widget ID base.
+		 *
+		 * This may be defined by the subclass. It may be exported from PHP to JS
+		 * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). If not,
+		 * it will attempt to be discovered by looking to see if this control
+		 * instance extends each member of component.controlConstructors, and if
+		 * it does extend one, will use the key as the id_base.
+		 *
+		 * @type {string}
+		 */
+		id_base: '',
+
+		/**
+		 * Mime type.
+		 *
+		 * This must be defined by the subclass. It may be exported from PHP to JS
+		 * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
+		 *
+		 * @type {string}
+		 */
+		mime_type: '',
+
+		/**
+		 * View events.
+		 *
+		 * @type {Object}
+		 */
+		events: {
+			'click .notice-missing-attachment a': 'handleMediaLibraryLinkClick',
+			'click .select-media': 'selectMedia',
+			'click .placeholder': 'selectMedia',
+			'click .edit-media': 'editMedia'
+		},
+
+		/**
+		 * Show display settings.
+		 *
+		 * @type {boolean}
+		 */
+		showDisplaySettings: true,
+
+		/**
+		 * Initialize.
+		 *
+		 * @param {Object}         options - Options.
+		 * @param {Backbone.Model} options.model - Model.
+		 * @param {jQuery}         options.el - Control field container element.
+		 * @param {jQuery}         options.syncContainer - Container element where fields are synced for the server.
+		 * @returns {void}
+		 */
+		initialize: function initialize( options ) {
+			var control = this;
+
+			Backbone.View.prototype.initialize.call( control, options );
+
+			if ( ! ( control.model instanceof component.MediaWidgetModel ) ) {
+				throw new Error( 'Missing options.model' );
+			}
+			if ( ! options.el ) {
+				throw new Error( 'Missing options.el' );
+			}
+			if ( ! options.syncContainer ) {
+				throw new Error( 'Missing options.syncContainer' );
+			}
+
+			control.syncContainer = options.syncContainer;
+
+			control.$el.addClass( 'media-widget-control' );
+
+			// Allow methods to be passed in with control context preserved.
+			_.bindAll( control, 'syncModelToInputs', 'render', 'updateSelectedAttachment', 'renderPreview' );
+
+			if ( ! control.id_base ) {
+				_.find( component.controlConstructors, function( Constructor, idBase ) {
+					if ( control instanceof Constructor ) {
+						control.id_base = idBase;
+						return true;
+					}
+					return false;
+				});
+				if ( ! control.id_base ) {
+					throw new Error( 'Missing id_base.' );
+				}
+			}
+
+			// Track attributes needed to renderPreview in it's own model.
+			control.previewTemplateProps = new Backbone.Model( control.mapModelToPreviewTemplateProps() );
+
+			// Re-render the preview when the attachment changes.
+			control.selectedAttachment = new wp.media.model.Attachment();
+			control.renderPreview = _.debounce( control.renderPreview );
+			control.listenTo( control.previewTemplateProps, 'change', control.renderPreview );
+
+			// Make sure a copy of the selected attachment is always fetched.
+			control.model.on( 'change:attachment_id', control.updateSelectedAttachment );
+			control.model.on( 'change:url', control.updateSelectedAttachment );
+			control.updateSelectedAttachment();
+
+			/*
+			 * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state.
+			 * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model
+			 * from the start, without having to sync with hidden fields. See <https://core.trac.wordpress.org/ticket/33507>.
+			 */
+			control.listenTo( control.model, 'change', control.syncModelToInputs );
+			control.listenTo( control.model, 'change', control.syncModelToPreviewProps );
+			control.listenTo( control.model, 'change', control.render );
+
+			// Update the title.
+			control.$el.on( 'input change', '.title', function updateTitle() {
+				control.model.set({
+					title: $.trim( $( this ).val() )
+				});
+			});
+
+			// Update link_url attribute.
+			control.$el.on( 'input change', '.link', function updateLinkUrl() {
+				var linkUrl = $.trim( $( this ).val() ), linkType = 'custom';
+				if ( control.selectedAttachment.get( 'linkUrl' ) === linkUrl || control.selectedAttachment.get( 'link' ) === linkUrl ) {
+					linkType = 'post';
+				} else if ( control.selectedAttachment.get( 'url' ) === linkUrl ) {
+					linkType = 'file';
+				}
+				control.model.set( {
+					link_url: linkUrl,
+					link_type: linkType
+				});
+
+				// Update display settings for the next time the user opens to select from the media library.
+				control.displaySettings.set( {
+					link: linkType,
+					linkUrl: linkUrl
+				});
+			});
+
+			/*
+			 * Copy current display settings from the widget model to serve as basis
+			 * of customized display settings for the current media frame session.
+			 * Changes to display settings will be synced into this model, and
+			 * when a new selection is made, the settings from this will be synced
+			 * into that AttachmentDisplay's model to persist the setting changes.
+			 */
+			control.displaySettings = new Backbone.Model( _.pick(
+				control.mapModelToMediaFrameProps(
+					_.extend( control.model.defaults(), control.model.toJSON() )
+				),
+				_.keys( wp.media.view.settings.defaultProps )
+			) );
+		},
+
+		/**
+		 * Update the selected attachment if necessary.
+		 *
+		 * @returns {void}
+		 */
+		updateSelectedAttachment: function updateSelectedAttachment() {
+			var control = this, attachment;
+
+			if ( 0 === control.model.get( 'attachment_id' ) ) {
+				control.selectedAttachment.clear();
+				control.model.set( 'error', false );
+			} else if ( control.model.get( 'attachment_id' ) !== control.selectedAttachment.get( 'id' ) ) {
+				attachment = new wp.media.model.Attachment({
+					id: control.model.get( 'attachment_id' )
+				});
+				attachment.fetch()
+					.done( function done() {
+						control.model.set( 'error', false );
+						control.selectedAttachment.set( attachment.toJSON() );
+					})
+					.fail( function fail() {
+						control.model.set( 'error', 'missing_attachment' );
+					});
+			}
+		},
+
+		/**
+		 * Sync the model attributes to the hidden inputs, and update previewTemplateProps.
+		 *
+		 * @returns {void}
+		 */
+		syncModelToPreviewProps: function syncModelToPreviewProps() {
+			var control = this;
+			control.previewTemplateProps.set( control.mapModelToPreviewTemplateProps() );
+		},
+
+		/**
+		 * Sync the model attributes to the hidden inputs, and update previewTemplateProps.
+		 *
+		 * @returns {void}
+		 */
+		syncModelToInputs: function syncModelToInputs() {
+			var control = this;
+			control.syncContainer.find( '.media-widget-instance-property' ).each( function() {
+				var input = $( this ), value, propertyName;
+				propertyName = input.data( 'property' );
+				value = control.model.get( propertyName );
+				if ( _.isUndefined( value ) ) {
+					return;
+				}
+
+				if ( 'array' === control.model.schema[ propertyName ].type && _.isArray( value ) ) {
+					value = value.join( ',' );
+				} else if ( 'boolean' === control.model.schema[ propertyName ].type ) {
+					value = value ? '1' : ''; // Because in PHP, strval( true ) === '1' && strval( false ) === ''.
+				} else {
+					value = String( value );
+				}
+
+				if ( input.val() !== value ) {
+					input.val( value );
+					input.trigger( 'change' );
+				}
+			});
+		},
+
+		/**
+		 * Get template.
+		 *
+		 * @returns {Function} Template.
+		 */
+		template: function template() {
+			var control = this;
+			if ( ! $( '#tmpl-widget-media-' + control.id_base + '-control' ).length ) {
+				throw new Error( 'Missing widget control template for ' + control.id_base );
+			}
+			return wp.template( 'widget-media-' + control.id_base + '-control' );
+		},
+
+		/**
+		 * Render template.
+		 *
+		 * @returns {void}
+		 */
+		render: function render() {
+			var control = this, titleInput;
+
+			if ( ! control.templateRendered ) {
+				control.$el.html( control.template()( control.model.toJSON() ) );
+				control.renderPreview(); // Hereafter it will re-render when control.selectedAttachment changes.
+				control.templateRendered = true;
+			}
+
+			titleInput = control.$el.find( '.title' );
+			if ( ! titleInput.is( document.activeElement ) ) {
+				titleInput.val( control.model.get( 'title' ) );
+			}
+
+			control.$el.toggleClass( 'selected', control.isSelected() );
+		},
+
+		/**
+		 * Render media preview.
+		 *
+		 * @abstract
+		 * @returns {void}
+		 */
+		renderPreview: function renderPreview() {
+			throw new Error( 'renderPreview must be implemented' );
+		},
+
+		/**
+		 * Whether a media item is selected.
+		 *
+		 * @returns {boolean} Whether selected and no error.
+		 */
+		isSelected: function isSelected() {
+			var control = this;
+
+			if ( control.model.get( 'error' ) ) {
+				return false;
+			}
+
+			return Boolean( control.model.get( 'attachment_id' ) || control.model.get( 'url' ) );
+		},
+
+		/**
+		 * Handle click on link to Media Library to open modal, such as the link that appears when in the missing attachment error notice.
+		 *
+		 * @param {jQuery.Event} event - Event.
+		 * @returns {void}
+		 */
+		handleMediaLibraryLinkClick: function handleMediaLibraryLinkClick( event ) {
+			var control = this;
+			event.preventDefault();
+			control.selectMedia();
+		},
+
+		/**
+		 * Open the media select frame to chose an item.
+		 *
+		 * @returns {void}
+		 */
+		selectMedia: function selectMedia() {
+			var control = this, selection, mediaFrame, defaultSync, mediaFrameProps, selectionModels = [];
+
+			if ( control.isSelected() && 0 !== control.model.get( 'attachment_id' ) ) {
+				selectionModels.push( control.selectedAttachment );
+			}
+
+			selection = new wp.media.model.Selection( selectionModels, { multiple: false } );
+
+			mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() );
+			if ( mediaFrameProps.size ) {
+				control.displaySettings.set( 'size', mediaFrameProps.size );
+			}
+
+			mediaFrame = new component.MediaFrameSelect({
+				title: control.l10n.add_media,
+				frame: 'post',
+				text: control.l10n.add_to_widget,
+				selection: selection,
+				mimeType: control.mime_type,
+				selectedDisplaySettings: control.displaySettings,
+				showDisplaySettings: control.showDisplaySettings,
+				metadata: mediaFrameProps,
+				state: control.isSelected() && 0 === control.model.get( 'attachment_id' ) ? 'embed' : 'insert',
+				invalidEmbedTypeError: control.l10n.unsupported_file_type
+			});
+			wp.media.frame = mediaFrame; // See wp.media().
+
+			// Handle selection of a media item.
+			mediaFrame.on( 'insert', function onInsert() {
+				var attachment = {}, state = mediaFrame.state();
+
+				// Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview.
+				if ( 'embed' === state.get( 'id' ) ) {
+					_.extend( attachment, { id: 0 }, state.props.toJSON() );
+				} else {
+					_.extend( attachment, state.get( 'selection' ).first().toJSON() );
+				}
+
+				control.selectedAttachment.set( attachment );
+				control.model.set( 'error', false );
+
+				// Update widget instance.
+				control.model.set( control.getModelPropsFromMediaFrame( mediaFrame ) );
+			});
+
+			// Disable syncing of attachment changes back to server (except for deletions). See <https://core.trac.wordpress.org/ticket/40403>.
+			defaultSync = wp.media.model.Attachment.prototype.sync;
+			wp.media.model.Attachment.prototype.sync = function( method ) {
+				if ( 'delete' === method ) {
+					return defaultSync.apply( this, arguments );
+				} else {
+					return $.Deferred().rejectWith( this ).promise();
+				}
+			};
+			mediaFrame.on( 'close', function onClose() {
+				wp.media.model.Attachment.prototype.sync = defaultSync;
+			});
+
+			mediaFrame.$el.addClass( 'media-widget' );
+			mediaFrame.open();
+
+			// Clear the selected attachment when it is deleted in the media select frame.
+			if ( selection ) {
+				selection.on( 'destroy', function onDestroy( attachment ) {
+					if ( control.model.get( 'attachment_id' ) === attachment.get( 'id' ) ) {
+						control.model.set({
+							attachment_id: 0,
+							url: ''
+						});
+					}
+				});
+			}
+
+			/*
+			 * Make sure focus is set inside of modal so that hitting Esc will close
+			 * the modal and not inadvertently cause the widget to collapse in the customizer.
+			 */
+			mediaFrame.$el.find( '.media-frame-menu .media-menu-item.active' ).focus();
+		},
+
+		/**
+		 * Get the instance props from the media selection frame.
+		 *
+		 * @param {wp.media.view.MediaFrame.Select} mediaFrame - Select frame.
+		 * @returns {Object} Props.
+		 */
+		getModelPropsFromMediaFrame: function getModelPropsFromMediaFrame( mediaFrame ) {
+			var control = this, state, mediaFrameProps, modelProps;
+
+			state = mediaFrame.state();
+			if ( 'insert' === state.get( 'id' ) ) {
+				mediaFrameProps = state.get( 'selection' ).first().toJSON();
+				mediaFrameProps.postUrl = mediaFrameProps.link;
+
+				if ( control.showDisplaySettings ) {
+					_.extend(
+						mediaFrameProps,
+						mediaFrame.content.get( '.attachments-browser' ).sidebar.get( 'display' ).model.toJSON()
+					);
+				}
+				if ( mediaFrameProps.sizes && mediaFrameProps.size && mediaFrameProps.sizes[ mediaFrameProps.size ] ) {
+					mediaFrameProps.url = mediaFrameProps.sizes[ mediaFrameProps.size ].url;
+				}
+			} else if ( 'embed' === state.get( 'id' ) ) {
+				mediaFrameProps = _.extend(
+					state.props.toJSON(),
+					{ attachment_id: 0 }, // Because some media frames use `attachment_id` not `id`.
+					control.model.getEmbedResetProps()
+				);
+			} else {
+				throw new Error( 'Unexpected state: ' + state.get( 'id' ) );
+			}
+
+			if ( mediaFrameProps.id ) {
+				mediaFrameProps.attachment_id = mediaFrameProps.id;
+			}
+
+			modelProps = control.mapMediaToModelProps( mediaFrameProps );
+
+			// Clear the extension prop so sources will be reset for video and audio media.
+			_.each( wp.media.view.settings.embedExts, function( ext ) {
+				if ( ext in control.model.schema && modelProps.url !== modelProps[ ext ] ) {
+					modelProps[ ext ] = '';
+				}
+			});
+
+			return modelProps;
+		},
+
+		/**
+		 * Map media frame props to model props.
+		 *
+		 * @param {Object} mediaFrameProps - Media frame props.
+		 * @returns {Object} Model props.
+		 */
+		mapMediaToModelProps: function mapMediaToModelProps( mediaFrameProps ) {
+			var control = this, mediaFramePropToModelPropMap = {}, modelProps = {}, extension;
+			_.each( control.model.schema, function( fieldSchema, modelProp ) {
+
+				// Ignore widget title attribute.
+				if ( 'title' === modelProp ) {
+					return;
+				}
+				mediaFramePropToModelPropMap[ fieldSchema.media_prop || modelProp ] = modelProp;
+			});
+
+			_.each( mediaFrameProps, function( value, mediaProp ) {
+				var propName = mediaFramePropToModelPropMap[ mediaProp ] || mediaProp;
+				if ( control.model.schema[ propName ] ) {
+					modelProps[ propName ] = value;
+				}
+			});
+
+			if ( 'custom' === mediaFrameProps.size ) {
+				modelProps.width = mediaFrameProps.customWidth;
+				modelProps.height = mediaFrameProps.customHeight;
+			}
+
+			if ( 'post' === mediaFrameProps.link ) {
+				modelProps.link_url = mediaFrameProps.postUrl || mediaFrameProps.linkUrl;
+			} else if ( 'file' === mediaFrameProps.link ) {
+				modelProps.link_url = mediaFrameProps.url;
+			}
+
+			// Because some media frames use `id` instead of `attachment_id`.
+			if ( ! mediaFrameProps.attachment_id && mediaFrameProps.id ) {
+				modelProps.attachment_id = mediaFrameProps.id;
+			}
+
+			if ( mediaFrameProps.url ) {
+				extension = mediaFrameProps.url.replace( /#.*$/, '' ).replace( /\?.*$/, '' ).split( '.' ).pop().toLowerCase();
+				if ( extension in control.model.schema ) {
+					modelProps[ extension ] = mediaFrameProps.url;
+				}
+			}
+
+			// Always omit the titles derived from mediaFrameProps.
+			return _.omit( modelProps, 'title' );
+		},
+
+		/**
+		 * Map model props to media frame props.
+		 *
+		 * @param {Object} modelProps - Model props.
+		 * @returns {Object} Media frame props.
+		 */
+		mapModelToMediaFrameProps: function mapModelToMediaFrameProps( modelProps ) {
+			var control = this, mediaFrameProps = {};
+
+			_.each( modelProps, function( value, modelProp ) {
+				var fieldSchema = control.model.schema[ modelProp ] || {};
+				mediaFrameProps[ fieldSchema.media_prop || modelProp ] = value;
+			});
+
+			// Some media frames use attachment_id.
+			mediaFrameProps.attachment_id = mediaFrameProps.id;
+
+			if ( 'custom' === mediaFrameProps.size ) {
+				mediaFrameProps.customWidth = control.model.get( 'width' );
+				mediaFrameProps.customHeight = control.model.get( 'height' );
+			}
+
+			return mediaFrameProps;
+		},
+
+		/**
+		 * Map model props to previewTemplateProps.
+		 *
+		 * @returns {Object} Preview Template Props.
+		 */
+		mapModelToPreviewTemplateProps: function mapModelToPreviewTemplateProps() {
+			var control = this, previewTemplateProps = {};
+			_.each( control.model.schema, function( value, prop ) {
+				if ( ! value.hasOwnProperty( 'should_preview_update' ) || value.should_preview_update ) {
+					previewTemplateProps[ prop ] = control.model.get( prop );
+				}
+			});
+
+			// Templates need to be aware of the error.
+			previewTemplateProps.error = control.model.get( 'error' );
+			return previewTemplateProps;
+		},
+
+		/**
+		 * Open the media frame to modify the selected item.
+		 *
+		 * @abstract
+		 * @returns {void}
+		 */
+		editMedia: function editMedia() {
+			throw new Error( 'editMedia not implemented' );
+		}
+	});
+
+	/**
+	 * Media widget model.
+	 *
+	 * @class MediaWidgetModel
+	 * @constructor
+	 */
+	component.MediaWidgetModel = Backbone.Model.extend({
+
+		/**
+		 * Id attribute.
+		 *
+		 * @type {string}
+		 */
+		idAttribute: 'widget_id',
+
+		/**
+		 * Instance schema.
+		 *
+		 * This adheres to JSON Schema and subclasses should have their schema
+		 * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
+		 *
+		 * @type {Object.<string, Object>}
+		 */
+		schema: {
+			title: {
+				type: 'string',
+				'default': ''
+			},
+			attachment_id: {
+				type: 'integer',
+				'default': 0
+			},
+			url: {
+				type: 'string',
+				'default': ''
+			}
+		},
+
+		/**
+		 * Get default attribute values.
+		 *
+		 * @returns {Object} Mapping of property names to their default values.
+		 */
+		defaults: function() {
+			var defaults = {};
+			_.each( this.schema, function( fieldSchema, field ) {
+				defaults[ field ] = fieldSchema['default'];
+			});
+			return defaults;
+		},
+
+		/**
+		 * Set attribute value(s).
+		 *
+		 * This is a wrapped version of Backbone.Model#set() which allows us to
+		 * cast the attribute values from the hidden inputs' string values into
+		 * the appropriate data types (integers or booleans).
+		 *
+		 * @param {string|Object} key - Attribute name or attribute pairs.
+		 * @param {mixed|Object}  [val] - Attribute value or options object.
+		 * @param {Object}        [options] - Options when attribute name and value are passed separately.
+		 * @returns {wp.mediaWidgets.MediaWidgetModel} This model.
+		 */
+		set: function set( key, val, options ) {
+			var model = this, attrs, opts, castedAttrs; // eslint-disable-line consistent-this
+			if ( null === key ) {
+				return model;
+			}
+			if ( 'object' === typeof key ) {
+				attrs = key;
+				opts = val;
+			} else {
+				attrs = {};
+				attrs[ key ] = val;
+				opts = options;
+			}
+
+			castedAttrs = {};
+			_.each( attrs, function( value, name ) {
+				var type;
+				if ( ! model.schema[ name ] ) {
+					castedAttrs[ name ] = value;
+					return;
+				}
+				type = model.schema[ name ].type;
+				if ( 'array' === type ) {
+					castedAttrs[ name ] = value;
+					if ( ! _.isArray( castedAttrs[ name ] ) ) {
+						castedAttrs[ name ] = castedAttrs[ name ].split( /,/ ); // Good enough for parsing an ID list.
+					}
+					if ( model.schema[ name ].items && 'integer' === model.schema[ name ].items.type ) {
+						castedAttrs[ name ] = _.filter(
+							_.map( castedAttrs[ name ], function( id ) {
+								return parseInt( id, 10 );
+							},
+							function( id ) {
+								return 'number' === typeof id;
+							}
+						) );
+					}
+				} else if ( 'integer' === type ) {
+					castedAttrs[ name ] = parseInt( value, 10 );
+				} else if ( 'boolean' === type ) {
+					castedAttrs[ name ] = ! ( ! value || '0' === value || 'false' === value );
+				} else {
+					castedAttrs[ name ] = value;
+				}
+			});
+
+			return Backbone.Model.prototype.set.call( this, castedAttrs, opts );
+		},
+
+		/**
+		 * Get props which are merged on top of the model when an embed is chosen (as opposed to an attachment).
+		 *
+		 * @returns {Object} Reset/override props.
+		 */
+		getEmbedResetProps: function getEmbedResetProps() {
+			return {
+				id: 0
+			};
+		}
+	});
+
+	/**
+	 * Collection of all widget model instances.
+	 *
+	 * @type {Backbone.Collection}
+	 */
+	component.modelCollection = new ( Backbone.Collection.extend({
+		model: component.MediaWidgetModel
+	}) )();
+
+	/**
+	 * Mapping of widget ID to instances of MediaWidgetControl subclasses.
+	 *
+	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetControl>}
+	 */
+	component.widgetControls = {};
+
+	/**
+	 * Handle widget being added or initialized for the first time at the widget-added event.
+	 *
+	 * @param {jQuery.Event} event - Event.
+	 * @param {jQuery}       widgetContainer - Widget container element.
+	 * @returns {void}
+	 */
+	component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
+		var fieldContainer, syncContainer, widgetForm, idBase, ControlConstructor, ModelConstructor, modelAttributes, widgetControl, widgetModel, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone;
+		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
+		idBase = widgetForm.find( '> .id_base' ).val();
+		widgetId = widgetForm.find( '> .widget-id' ).val();
+
+		// Prevent initializing already-added widgets.
+		if ( component.widgetControls[ widgetId ] ) {
+			return;
+		}
+
+		ControlConstructor = component.controlConstructors[ idBase ];
+		if ( ! ControlConstructor ) {
+			return;
+		}
+
+		ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel;
+
+		/*
+		 * Create a container element for the widget control (Backbone.View).
+		 * This is inserted into the DOM immediately before the .widget-content
+		 * element because the contents of this element are essentially "managed"
+		 * by PHP, where each widget update cause the entire element to be emptied
+		 * and replaced with the rendered output of WP_Widget::form() which is
+		 * sent back in Ajax request made to save/update the widget instance.
+		 * To prevent a "flash of replaced DOM elements and re-initialized JS
+		 * components", the JS template is rendered outside of the normal form
+		 * container.
+		 */
+		fieldContainer = $( '<div></div>' );
+		syncContainer = widgetContainer.find( '.widget-content:first' );
+		syncContainer.before( fieldContainer );
+
+		/*
+		 * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state.
+		 * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model
+		 * from the start, without having to sync with hidden fields. See <https://core.trac.wordpress.org/ticket/33507>.
+		 */
+		modelAttributes = {};
+		syncContainer.find( '.media-widget-instance-property' ).each( function() {
+			var input = $( this );
+			modelAttributes[ input.data( 'property' ) ] = input.val();
+		});
+		modelAttributes.widget_id = widgetId;
+
+		widgetModel = new ModelConstructor( modelAttributes );
+
+		widgetControl = new ControlConstructor({
+			el: fieldContainer,
+			syncContainer: syncContainer,
+			model: widgetModel
+		});
+
+		/*
+		 * Render the widget once the widget parent's container finishes animating,
+		 * as the widget-added event fires with a slideDown of the container.
+		 * This ensures that the container's dimensions are fixed so that ME.js
+		 * can initialize with the proper dimensions.
+		 */
+		renderWhenAnimationDone = function() {
+			if ( ! widgetContainer.hasClass( 'open' ) ) {
+				setTimeout( renderWhenAnimationDone, animatedCheckDelay );
+			} else {
+				widgetControl.render();
+			}
+		};
+		renderWhenAnimationDone();
+
+		/*
+		 * Note that the model and control currently won't ever get garbage-collected
+		 * when a widget gets removed/deleted because there is no widget-removed event.
+		 */
+		component.modelCollection.add( [ widgetModel ] );
+		component.widgetControls[ widgetModel.get( 'widget_id' ) ] = widgetControl;
+	};
+
+	/**
+	 * Setup widget in accessibility mode.
+	 *
+	 * @returns {void}
+	 */
+	component.setupAccessibleMode = function setupAccessibleMode() {
+		var widgetForm, widgetId, idBase, widgetControl, ControlConstructor, ModelConstructor, modelAttributes, fieldContainer, syncContainer;
+		widgetForm = $( '.editwidget > form' );
+		if ( 0 === widgetForm.length ) {
+			return;
+		}
+
+		idBase = widgetForm.find( '> .widget-control-actions > .id_base' ).val();
+
+		ControlConstructor = component.controlConstructors[ idBase ];
+		if ( ! ControlConstructor ) {
+			return;
+		}
+
+		widgetId = widgetForm.find( '> .widget-control-actions > .widget-id' ).val();
+
+		ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel;
+		fieldContainer = $( '<div></div>' );
+		syncContainer = widgetForm.find( '> .widget-inside' );
+		syncContainer.before( fieldContainer );
+
+		modelAttributes = {};
+		syncContainer.find( '.media-widget-instance-property' ).each( function() {
+			var input = $( this );
+			modelAttributes[ input.data( 'property' ) ] = input.val();
+		});
+		modelAttributes.widget_id = widgetId;
+
+		widgetControl = new ControlConstructor({
+			el: fieldContainer,
+			syncContainer: syncContainer,
+			model: new ModelConstructor( modelAttributes )
+		});
+
+		component.modelCollection.add( [ widgetControl.model ] );
+		component.widgetControls[ widgetControl.model.get( 'widget_id' ) ] = widgetControl;
+
+		widgetControl.render();
+	};
+
+	/**
+	 * Sync widget instance data sanitized from server back onto widget model.
+	 *
+	 * This gets called via the 'widget-updated' event when saving a widget from
+	 * the widgets admin screen and also via the 'widget-synced' event when making
+	 * a change to a widget in the customizer.
+	 *
+	 * @param {jQuery.Event} event - Event.
+	 * @param {jQuery}       widgetContainer - Widget container element.
+	 * @returns {void}
+	 */
+	component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
+		var widgetForm, widgetContent, widgetId, widgetControl, attributes = {};
+		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
+		widgetId = widgetForm.find( '> .widget-id' ).val();
+
+		widgetControl = component.widgetControls[ widgetId ];
+		if ( ! widgetControl ) {
+			return;
+		}
+
+		// Make sure the server-sanitized values get synced back into the model.
+		widgetContent = widgetForm.find( '> .widget-content' );
+		widgetContent.find( '.media-widget-instance-property' ).each( function() {
+			var property = $( this ).data( 'property' );
+			attributes[ property ] = $( this ).val();
+		});
+
+		// Suspend syncing model back to inputs when syncing from inputs to model, preventing infinite loop.
+		widgetControl.stopListening( widgetControl.model, 'change', widgetControl.syncModelToInputs );
+		widgetControl.model.set( attributes );
+		widgetControl.listenTo( widgetControl.model, 'change', widgetControl.syncModelToInputs );
+	};
+
+	/**
+	 * Initialize functionality.
+	 *
+	 * This function exists to prevent the JS file from having to boot itself.
+	 * When WordPress enqueues this script, it should have an inline script
+	 * attached which calls wp.mediaWidgets.init().
+	 *
+	 * @returns {void}
+	 */
+	component.init = function init() {
+		var $document = $( document );
+		$document.on( 'widget-added', component.handleWidgetAdded );
+		$document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
+
+		/*
+		 * Manually trigger widget-added events for media widgets on the admin
+		 * screen once they are expanded. The widget-added event is not triggered
+		 * for each pre-existing widget on the widgets admin screen like it is
+		 * on the customizer. Likewise, the customizer only triggers widget-added
+		 * when the widget is expanded to just-in-time construct the widget form
+		 * when it is actually going to be displayed. So the following implements
+		 * the same for the widgets admin screen, to invoke the widget-added
+		 * handler when a pre-existing media widget is expanded.
+		 */
+		$( function initializeExistingWidgetContainers() {
+			var widgetContainers;
+			if ( 'widgets' !== window.pagenow ) {
+				return;
+			}
+			widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
+			widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
+				var widgetContainer = $( this );
+				component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
+			});
+
+			// Accessibility mode.
+			$( window ).on( 'load', function() {
+				component.setupAccessibleMode();
+			});
+		});
+	};
+
+	return component;
+})( jQuery );