balmet.com

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

media-widgets.js (42851B)


      1 /**
      2  * @output wp-admin/js/widgets/media-widgets.js
      3  */
      4 
      5 /* eslint consistent-this: [ "error", "control" ] */
      6 
      7 /**
      8  * @namespace wp.mediaWidgets
      9  * @memberOf  wp
     10  */
     11 wp.mediaWidgets = ( function( $ ) {
     12 	'use strict';
     13 
     14 	var component = {};
     15 
     16 	/**
     17 	 * Widget control (view) constructors, mapping widget id_base to subclass of MediaWidgetControl.
     18 	 *
     19 	 * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base.
     20 	 *
     21 	 * @memberOf wp.mediaWidgets
     22 	 *
     23 	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetModel>}
     24 	 */
     25 	component.controlConstructors = {};
     26 
     27 	/**
     28 	 * Widget model constructors, mapping widget id_base to subclass of MediaWidgetModel.
     29 	 *
     30 	 * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base.
     31 	 *
     32 	 * @memberOf wp.mediaWidgets
     33 	 *
     34 	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetModel>}
     35 	 */
     36 	component.modelConstructors = {};
     37 
     38 	component.PersistentDisplaySettingsLibrary = wp.media.controller.Library.extend(/** @lends wp.mediaWidgets.PersistentDisplaySettingsLibrary.prototype */{
     39 
     40 		/**
     41 		 * Library which persists the customized display settings across selections.
     42 		 *
     43 		 * @constructs wp.mediaWidgets.PersistentDisplaySettingsLibrary
     44 		 * @augments   wp.media.controller.Library
     45 		 *
     46 		 * @param {Object} options - Options.
     47 		 *
     48 		 * @return {void}
     49 		 */
     50 		initialize: function initialize( options ) {
     51 			_.bindAll( this, 'handleDisplaySettingChange' );
     52 			wp.media.controller.Library.prototype.initialize.call( this, options );
     53 		},
     54 
     55 		/**
     56 		 * Sync changes to the current display settings back into the current customized.
     57 		 *
     58 		 * @param {Backbone.Model} displaySettings - Modified display settings.
     59 		 * @return {void}
     60 		 */
     61 		handleDisplaySettingChange: function handleDisplaySettingChange( displaySettings ) {
     62 			this.get( 'selectedDisplaySettings' ).set( displaySettings.attributes );
     63 		},
     64 
     65 		/**
     66 		 * Get the display settings model.
     67 		 *
     68 		 * Model returned is updated with the current customized display settings,
     69 		 * and an event listener is added so that changes made to the settings
     70 		 * will sync back into the model storing the session's customized display
     71 		 * settings.
     72 		 *
     73 		 * @param {Backbone.Model} model - Display settings model.
     74 		 * @return {Backbone.Model} Display settings model.
     75 		 */
     76 		display: function getDisplaySettingsModel( model ) {
     77 			var display, selectedDisplaySettings = this.get( 'selectedDisplaySettings' );
     78 			display = wp.media.controller.Library.prototype.display.call( this, model );
     79 
     80 			display.off( 'change', this.handleDisplaySettingChange ); // Prevent duplicated event handlers.
     81 			display.set( selectedDisplaySettings.attributes );
     82 			if ( 'custom' === selectedDisplaySettings.get( 'link_type' ) ) {
     83 				display.linkUrl = selectedDisplaySettings.get( 'link_url' );
     84 			}
     85 			display.on( 'change', this.handleDisplaySettingChange );
     86 			return display;
     87 		}
     88 	});
     89 
     90 	/**
     91 	 * Extended view for managing the embed UI.
     92 	 *
     93 	 * @class    wp.mediaWidgets.MediaEmbedView
     94 	 * @augments wp.media.view.Embed
     95 	 */
     96 	component.MediaEmbedView = wp.media.view.Embed.extend(/** @lends wp.mediaWidgets.MediaEmbedView.prototype */{
     97 
     98 		/**
     99 		 * Initialize.
    100 		 *
    101 		 * @since 4.9.0
    102 		 *
    103 		 * @param {Object} options - Options.
    104 		 * @return {void}
    105 		 */
    106 		initialize: function( options ) {
    107 			var view = this, embedController; // eslint-disable-line consistent-this
    108 			wp.media.view.Embed.prototype.initialize.call( view, options );
    109 			if ( 'image' !== view.controller.options.mimeType ) {
    110 				embedController = view.controller.states.get( 'embed' );
    111 				embedController.off( 'scan', embedController.scanImage, embedController );
    112 			}
    113 		},
    114 
    115 		/**
    116 		 * Refresh embed view.
    117 		 *
    118 		 * Forked override of {wp.media.view.Embed#refresh()} to suppress irrelevant "link text" field.
    119 		 *
    120 		 * @return {void}
    121 		 */
    122 		refresh: function refresh() {
    123 			/**
    124 			 * @class wp.mediaWidgets~Constructor
    125 			 */
    126 			var Constructor;
    127 
    128 			if ( 'image' === this.controller.options.mimeType ) {
    129 				Constructor = wp.media.view.EmbedImage;
    130 			} else {
    131 
    132 				// This should be eliminated once #40450 lands of when this is merged into core.
    133 				Constructor = wp.media.view.EmbedLink.extend(/** @lends wp.mediaWidgets~Constructor.prototype */{
    134 
    135 					/**
    136 					 * Set the disabled state on the Add to Widget button.
    137 					 *
    138 					 * @param {boolean} disabled - Disabled.
    139 					 * @return {void}
    140 					 */
    141 					setAddToWidgetButtonDisabled: function setAddToWidgetButtonDisabled( disabled ) {
    142 						this.views.parent.views.parent.views.get( '.media-frame-toolbar' )[0].$el.find( '.media-button-select' ).prop( 'disabled', disabled );
    143 					},
    144 
    145 					/**
    146 					 * Set or clear an error notice.
    147 					 *
    148 					 * @param {string} notice - Notice.
    149 					 * @return {void}
    150 					 */
    151 					setErrorNotice: function setErrorNotice( notice ) {
    152 						var embedLinkView = this, noticeContainer; // eslint-disable-line consistent-this
    153 
    154 						noticeContainer = embedLinkView.views.parent.$el.find( '> .notice:first-child' );
    155 						if ( ! notice ) {
    156 							if ( noticeContainer.length ) {
    157 								noticeContainer.slideUp( 'fast' );
    158 							}
    159 						} else {
    160 							if ( ! noticeContainer.length ) {
    161 								noticeContainer = $( '<div class="media-widget-embed-notice notice notice-error notice-alt"></div>' );
    162 								noticeContainer.hide();
    163 								embedLinkView.views.parent.$el.prepend( noticeContainer );
    164 							}
    165 							noticeContainer.empty();
    166 							noticeContainer.append( $( '<p>', {
    167 								html: notice
    168 							}));
    169 							noticeContainer.slideDown( 'fast' );
    170 						}
    171 					},
    172 
    173 					/**
    174 					 * Update oEmbed.
    175 					 *
    176 					 * @since 4.9.0
    177 					 *
    178 					 * @return {void}
    179 					 */
    180 					updateoEmbed: function() {
    181 						var embedLinkView = this, url; // eslint-disable-line consistent-this
    182 
    183 						url = embedLinkView.model.get( 'url' );
    184 
    185 						// Abort if the URL field was emptied out.
    186 						if ( ! url ) {
    187 							embedLinkView.setErrorNotice( '' );
    188 							embedLinkView.setAddToWidgetButtonDisabled( true );
    189 							return;
    190 						}
    191 
    192 						if ( ! url.match( /^(http|https):\/\/.+\// ) ) {
    193 							embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' );
    194 							embedLinkView.setAddToWidgetButtonDisabled( true );
    195 						}
    196 
    197 						wp.media.view.EmbedLink.prototype.updateoEmbed.call( embedLinkView );
    198 					},
    199 
    200 					/**
    201 					 * Fetch media.
    202 					 *
    203 					 * @return {void}
    204 					 */
    205 					fetch: function() {
    206 						var embedLinkView = this, fetchSuccess, matches, fileExt, urlParser, url, re, youTubeEmbedMatch; // eslint-disable-line consistent-this
    207 						url = embedLinkView.model.get( 'url' );
    208 
    209 						if ( embedLinkView.dfd && 'pending' === embedLinkView.dfd.state() ) {
    210 							embedLinkView.dfd.abort();
    211 						}
    212 
    213 						fetchSuccess = function( response ) {
    214 							embedLinkView.renderoEmbed({
    215 								data: {
    216 									body: response
    217 								}
    218 							});
    219 
    220 							embedLinkView.controller.$el.find( '#embed-url-field' ).removeClass( 'invalid' );
    221 							embedLinkView.setErrorNotice( '' );
    222 							embedLinkView.setAddToWidgetButtonDisabled( false );
    223 						};
    224 
    225 						urlParser = document.createElement( 'a' );
    226 						urlParser.href = url;
    227 						matches = urlParser.pathname.toLowerCase().match( /\.(\w+)$/ );
    228 						if ( matches ) {
    229 							fileExt = matches[1];
    230 							if ( ! wp.media.view.settings.embedMimes[ fileExt ] ) {
    231 								embedLinkView.renderFail();
    232 							} else if ( 0 !== wp.media.view.settings.embedMimes[ fileExt ].indexOf( embedLinkView.controller.options.mimeType ) ) {
    233 								embedLinkView.renderFail();
    234 							} else {
    235 								fetchSuccess( '<!--success-->' );
    236 							}
    237 							return;
    238 						}
    239 
    240 						// Support YouTube embed links.
    241 						re = /https?:\/\/www\.youtube\.com\/embed\/([^/]+)/;
    242 						youTubeEmbedMatch = re.exec( url );
    243 						if ( youTubeEmbedMatch ) {
    244 							url = 'https://www.youtube.com/watch?v=' + youTubeEmbedMatch[ 1 ];
    245 							// silently change url to proper oembed-able version.
    246 							embedLinkView.model.attributes.url = url;
    247 						}
    248 
    249 						embedLinkView.dfd = wp.apiRequest({
    250 							url: wp.media.view.settings.oEmbedProxyUrl,
    251 							data: {
    252 								url: url,
    253 								maxwidth: embedLinkView.model.get( 'width' ),
    254 								maxheight: embedLinkView.model.get( 'height' ),
    255 								discover: false
    256 							},
    257 							type: 'GET',
    258 							dataType: 'json',
    259 							context: embedLinkView
    260 						});
    261 
    262 						embedLinkView.dfd.done( function( response ) {
    263 							if ( embedLinkView.controller.options.mimeType !== response.type ) {
    264 								embedLinkView.renderFail();
    265 								return;
    266 							}
    267 							fetchSuccess( response.html );
    268 						});
    269 						embedLinkView.dfd.fail( _.bind( embedLinkView.renderFail, embedLinkView ) );
    270 					},
    271 
    272 					/**
    273 					 * Handle render failure.
    274 					 *
    275 					 * Overrides the {EmbedLink#renderFail()} method to prevent showing the "Link Text" field.
    276 					 * The element is getting display:none in the stylesheet, but the underlying method uses
    277 					 * uses {jQuery.fn.show()} which adds an inline style. This avoids the need for !important.
    278 					 *
    279 					 * @return {void}
    280 					 */
    281 					renderFail: function renderFail() {
    282 						var embedLinkView = this; // eslint-disable-line consistent-this
    283 						embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' );
    284 						embedLinkView.setErrorNotice( embedLinkView.controller.options.invalidEmbedTypeError || 'ERROR' );
    285 						embedLinkView.setAddToWidgetButtonDisabled( true );
    286 					}
    287 				});
    288 			}
    289 
    290 			this.settings( new Constructor({
    291 				controller: this.controller,
    292 				model:      this.model.props,
    293 				priority:   40
    294 			}));
    295 		}
    296 	});
    297 
    298 	/**
    299 	 * Custom media frame for selecting uploaded media or providing media by URL.
    300 	 *
    301 	 * @class    wp.mediaWidgets.MediaFrameSelect
    302 	 * @augments wp.media.view.MediaFrame.Post
    303 	 */
    304 	component.MediaFrameSelect = wp.media.view.MediaFrame.Post.extend(/** @lends wp.mediaWidgets.MediaFrameSelect.prototype */{
    305 
    306 		/**
    307 		 * Create the default states.
    308 		 *
    309 		 * @return {void}
    310 		 */
    311 		createStates: function createStates() {
    312 			var mime = this.options.mimeType, specificMimes = [];
    313 			_.each( wp.media.view.settings.embedMimes, function( embedMime ) {
    314 				if ( 0 === embedMime.indexOf( mime ) ) {
    315 					specificMimes.push( embedMime );
    316 				}
    317 			});
    318 			if ( specificMimes.length > 0 ) {
    319 				mime = specificMimes;
    320 			}
    321 
    322 			this.states.add([
    323 
    324 				// Main states.
    325 				new component.PersistentDisplaySettingsLibrary({
    326 					id:         'insert',
    327 					title:      this.options.title,
    328 					selection:  this.options.selection,
    329 					priority:   20,
    330 					toolbar:    'main-insert',
    331 					filterable: 'dates',
    332 					library:    wp.media.query({
    333 						type: mime
    334 					}),
    335 					multiple:   false,
    336 					editable:   true,
    337 
    338 					selectedDisplaySettings: this.options.selectedDisplaySettings,
    339 					displaySettings: _.isUndefined( this.options.showDisplaySettings ) ? true : this.options.showDisplaySettings,
    340 					displayUserSettings: false // We use the display settings from the current/default widget instance props.
    341 				}),
    342 
    343 				new wp.media.controller.EditImage({ model: this.options.editImage }),
    344 
    345 				// Embed states.
    346 				new wp.media.controller.Embed({
    347 					metadata: this.options.metadata,
    348 					type: 'image' === this.options.mimeType ? 'image' : 'link',
    349 					invalidEmbedTypeError: this.options.invalidEmbedTypeError
    350 				})
    351 			]);
    352 		},
    353 
    354 		/**
    355 		 * Main insert toolbar.
    356 		 *
    357 		 * Forked override of {wp.media.view.MediaFrame.Post#mainInsertToolbar()} to override text.
    358 		 *
    359 		 * @param {wp.Backbone.View} view - Toolbar view.
    360 		 * @this {wp.media.controller.Library}
    361 		 * @return {void}
    362 		 */
    363 		mainInsertToolbar: function mainInsertToolbar( view ) {
    364 			var controller = this; // eslint-disable-line consistent-this
    365 			view.set( 'insert', {
    366 				style:    'primary',
    367 				priority: 80,
    368 				text:     controller.options.text, // The whole reason for the fork.
    369 				requires: { selection: true },
    370 
    371 				/**
    372 				 * Handle click.
    373 				 *
    374 				 * @ignore
    375 				 *
    376 				 * @fires wp.media.controller.State#insert()
    377 				 * @return {void}
    378 				 */
    379 				click: function onClick() {
    380 					var state = controller.state(),
    381 						selection = state.get( 'selection' );
    382 
    383 					controller.close();
    384 					state.trigger( 'insert', selection ).reset();
    385 				}
    386 			});
    387 		},
    388 
    389 		/**
    390 		 * Main embed toolbar.
    391 		 *
    392 		 * Forked override of {wp.media.view.MediaFrame.Post#mainEmbedToolbar()} to override text.
    393 		 *
    394 		 * @param {wp.Backbone.View} toolbar - Toolbar view.
    395 		 * @this {wp.media.controller.Library}
    396 		 * @return {void}
    397 		 */
    398 		mainEmbedToolbar: function mainEmbedToolbar( toolbar ) {
    399 			toolbar.view = new wp.media.view.Toolbar.Embed({
    400 				controller: this,
    401 				text: this.options.text,
    402 				event: 'insert'
    403 			});
    404 		},
    405 
    406 		/**
    407 		 * Embed content.
    408 		 *
    409 		 * Forked override of {wp.media.view.MediaFrame.Post#embedContent()} to suppress irrelevant "link text" field.
    410 		 *
    411 		 * @return {void}
    412 		 */
    413 		embedContent: function embedContent() {
    414 			var view = new component.MediaEmbedView({
    415 				controller: this,
    416 				model:      this.state()
    417 			}).render();
    418 
    419 			this.content.set( view );
    420 		}
    421 	});
    422 
    423 	component.MediaWidgetControl = Backbone.View.extend(/** @lends wp.mediaWidgets.MediaWidgetControl.prototype */{
    424 
    425 		/**
    426 		 * Translation strings.
    427 		 *
    428 		 * The mapping of translation strings is handled by media widget subclasses,
    429 		 * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
    430 		 *
    431 		 * @type {Object}
    432 		 */
    433 		l10n: {
    434 			add_to_widget: '{{add_to_widget}}',
    435 			add_media: '{{add_media}}'
    436 		},
    437 
    438 		/**
    439 		 * Widget ID base.
    440 		 *
    441 		 * This may be defined by the subclass. It may be exported from PHP to JS
    442 		 * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). If not,
    443 		 * it will attempt to be discovered by looking to see if this control
    444 		 * instance extends each member of component.controlConstructors, and if
    445 		 * it does extend one, will use the key as the id_base.
    446 		 *
    447 		 * @type {string}
    448 		 */
    449 		id_base: '',
    450 
    451 		/**
    452 		 * Mime type.
    453 		 *
    454 		 * This must be defined by the subclass. It may be exported from PHP to JS
    455 		 * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
    456 		 *
    457 		 * @type {string}
    458 		 */
    459 		mime_type: '',
    460 
    461 		/**
    462 		 * View events.
    463 		 *
    464 		 * @type {Object}
    465 		 */
    466 		events: {
    467 			'click .notice-missing-attachment a': 'handleMediaLibraryLinkClick',
    468 			'click .select-media': 'selectMedia',
    469 			'click .placeholder': 'selectMedia',
    470 			'click .edit-media': 'editMedia'
    471 		},
    472 
    473 		/**
    474 		 * Show display settings.
    475 		 *
    476 		 * @type {boolean}
    477 		 */
    478 		showDisplaySettings: true,
    479 
    480 		/**
    481 		 * Media Widget Control.
    482 		 *
    483 		 * @constructs wp.mediaWidgets.MediaWidgetControl
    484 		 * @augments   Backbone.View
    485 		 * @abstract
    486 		 *
    487 		 * @param {Object}         options - Options.
    488 		 * @param {Backbone.Model} options.model - Model.
    489 		 * @param {jQuery}         options.el - Control field container element.
    490 		 * @param {jQuery}         options.syncContainer - Container element where fields are synced for the server.
    491 		 *
    492 		 * @return {void}
    493 		 */
    494 		initialize: function initialize( options ) {
    495 			var control = this;
    496 
    497 			Backbone.View.prototype.initialize.call( control, options );
    498 
    499 			if ( ! ( control.model instanceof component.MediaWidgetModel ) ) {
    500 				throw new Error( 'Missing options.model' );
    501 			}
    502 			if ( ! options.el ) {
    503 				throw new Error( 'Missing options.el' );
    504 			}
    505 			if ( ! options.syncContainer ) {
    506 				throw new Error( 'Missing options.syncContainer' );
    507 			}
    508 
    509 			control.syncContainer = options.syncContainer;
    510 
    511 			control.$el.addClass( 'media-widget-control' );
    512 
    513 			// Allow methods to be passed in with control context preserved.
    514 			_.bindAll( control, 'syncModelToInputs', 'render', 'updateSelectedAttachment', 'renderPreview' );
    515 
    516 			if ( ! control.id_base ) {
    517 				_.find( component.controlConstructors, function( Constructor, idBase ) {
    518 					if ( control instanceof Constructor ) {
    519 						control.id_base = idBase;
    520 						return true;
    521 					}
    522 					return false;
    523 				});
    524 				if ( ! control.id_base ) {
    525 					throw new Error( 'Missing id_base.' );
    526 				}
    527 			}
    528 
    529 			// Track attributes needed to renderPreview in it's own model.
    530 			control.previewTemplateProps = new Backbone.Model( control.mapModelToPreviewTemplateProps() );
    531 
    532 			// Re-render the preview when the attachment changes.
    533 			control.selectedAttachment = new wp.media.model.Attachment();
    534 			control.renderPreview = _.debounce( control.renderPreview );
    535 			control.listenTo( control.previewTemplateProps, 'change', control.renderPreview );
    536 
    537 			// Make sure a copy of the selected attachment is always fetched.
    538 			control.model.on( 'change:attachment_id', control.updateSelectedAttachment );
    539 			control.model.on( 'change:url', control.updateSelectedAttachment );
    540 			control.updateSelectedAttachment();
    541 
    542 			/*
    543 			 * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state.
    544 			 * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model
    545 			 * from the start, without having to sync with hidden fields. See <https://core.trac.wordpress.org/ticket/33507>.
    546 			 */
    547 			control.listenTo( control.model, 'change', control.syncModelToInputs );
    548 			control.listenTo( control.model, 'change', control.syncModelToPreviewProps );
    549 			control.listenTo( control.model, 'change', control.render );
    550 
    551 			// Update the title.
    552 			control.$el.on( 'input change', '.title', function updateTitle() {
    553 				control.model.set({
    554 					title: $( this ).val().trim()
    555 				});
    556 			});
    557 
    558 			// Update link_url attribute.
    559 			control.$el.on( 'input change', '.link', function updateLinkUrl() {
    560 				var linkUrl = $( this ).val().trim(), linkType = 'custom';
    561 				if ( control.selectedAttachment.get( 'linkUrl' ) === linkUrl || control.selectedAttachment.get( 'link' ) === linkUrl ) {
    562 					linkType = 'post';
    563 				} else if ( control.selectedAttachment.get( 'url' ) === linkUrl ) {
    564 					linkType = 'file';
    565 				}
    566 				control.model.set( {
    567 					link_url: linkUrl,
    568 					link_type: linkType
    569 				});
    570 
    571 				// Update display settings for the next time the user opens to select from the media library.
    572 				control.displaySettings.set( {
    573 					link: linkType,
    574 					linkUrl: linkUrl
    575 				});
    576 			});
    577 
    578 			/*
    579 			 * Copy current display settings from the widget model to serve as basis
    580 			 * of customized display settings for the current media frame session.
    581 			 * Changes to display settings will be synced into this model, and
    582 			 * when a new selection is made, the settings from this will be synced
    583 			 * into that AttachmentDisplay's model to persist the setting changes.
    584 			 */
    585 			control.displaySettings = new Backbone.Model( _.pick(
    586 				control.mapModelToMediaFrameProps(
    587 					_.extend( control.model.defaults(), control.model.toJSON() )
    588 				),
    589 				_.keys( wp.media.view.settings.defaultProps )
    590 			) );
    591 		},
    592 
    593 		/**
    594 		 * Update the selected attachment if necessary.
    595 		 *
    596 		 * @return {void}
    597 		 */
    598 		updateSelectedAttachment: function updateSelectedAttachment() {
    599 			var control = this, attachment;
    600 
    601 			if ( 0 === control.model.get( 'attachment_id' ) ) {
    602 				control.selectedAttachment.clear();
    603 				control.model.set( 'error', false );
    604 			} else if ( control.model.get( 'attachment_id' ) !== control.selectedAttachment.get( 'id' ) ) {
    605 				attachment = new wp.media.model.Attachment({
    606 					id: control.model.get( 'attachment_id' )
    607 				});
    608 				attachment.fetch()
    609 					.done( function done() {
    610 						control.model.set( 'error', false );
    611 						control.selectedAttachment.set( attachment.toJSON() );
    612 					})
    613 					.fail( function fail() {
    614 						control.model.set( 'error', 'missing_attachment' );
    615 					});
    616 			}
    617 		},
    618 
    619 		/**
    620 		 * Sync the model attributes to the hidden inputs, and update previewTemplateProps.
    621 		 *
    622 		 * @return {void}
    623 		 */
    624 		syncModelToPreviewProps: function syncModelToPreviewProps() {
    625 			var control = this;
    626 			control.previewTemplateProps.set( control.mapModelToPreviewTemplateProps() );
    627 		},
    628 
    629 		/**
    630 		 * Sync the model attributes to the hidden inputs, and update previewTemplateProps.
    631 		 *
    632 		 * @return {void}
    633 		 */
    634 		syncModelToInputs: function syncModelToInputs() {
    635 			var control = this;
    636 			control.syncContainer.find( '.media-widget-instance-property' ).each( function() {
    637 				var input = $( this ), value, propertyName;
    638 				propertyName = input.data( 'property' );
    639 				value = control.model.get( propertyName );
    640 				if ( _.isUndefined( value ) ) {
    641 					return;
    642 				}
    643 
    644 				if ( 'array' === control.model.schema[ propertyName ].type && _.isArray( value ) ) {
    645 					value = value.join( ',' );
    646 				} else if ( 'boolean' === control.model.schema[ propertyName ].type ) {
    647 					value = value ? '1' : ''; // Because in PHP, strval( true ) === '1' && strval( false ) === ''.
    648 				} else {
    649 					value = String( value );
    650 				}
    651 
    652 				if ( input.val() !== value ) {
    653 					input.val( value );
    654 					input.trigger( 'change' );
    655 				}
    656 			});
    657 		},
    658 
    659 		/**
    660 		 * Get template.
    661 		 *
    662 		 * @return {Function} Template.
    663 		 */
    664 		template: function template() {
    665 			var control = this;
    666 			if ( ! $( '#tmpl-widget-media-' + control.id_base + '-control' ).length ) {
    667 				throw new Error( 'Missing widget control template for ' + control.id_base );
    668 			}
    669 			return wp.template( 'widget-media-' + control.id_base + '-control' );
    670 		},
    671 
    672 		/**
    673 		 * Render template.
    674 		 *
    675 		 * @return {void}
    676 		 */
    677 		render: function render() {
    678 			var control = this, titleInput;
    679 
    680 			if ( ! control.templateRendered ) {
    681 				control.$el.html( control.template()( control.model.toJSON() ) );
    682 				control.renderPreview(); // Hereafter it will re-render when control.selectedAttachment changes.
    683 				control.templateRendered = true;
    684 			}
    685 
    686 			titleInput = control.$el.find( '.title' );
    687 			if ( ! titleInput.is( document.activeElement ) ) {
    688 				titleInput.val( control.model.get( 'title' ) );
    689 			}
    690 
    691 			control.$el.toggleClass( 'selected', control.isSelected() );
    692 		},
    693 
    694 		/**
    695 		 * Render media preview.
    696 		 *
    697 		 * @abstract
    698 		 * @return {void}
    699 		 */
    700 		renderPreview: function renderPreview() {
    701 			throw new Error( 'renderPreview must be implemented' );
    702 		},
    703 
    704 		/**
    705 		 * Whether a media item is selected.
    706 		 *
    707 		 * @return {boolean} Whether selected and no error.
    708 		 */
    709 		isSelected: function isSelected() {
    710 			var control = this;
    711 
    712 			if ( control.model.get( 'error' ) ) {
    713 				return false;
    714 			}
    715 
    716 			return Boolean( control.model.get( 'attachment_id' ) || control.model.get( 'url' ) );
    717 		},
    718 
    719 		/**
    720 		 * Handle click on link to Media Library to open modal, such as the link that appears when in the missing attachment error notice.
    721 		 *
    722 		 * @param {jQuery.Event} event - Event.
    723 		 * @return {void}
    724 		 */
    725 		handleMediaLibraryLinkClick: function handleMediaLibraryLinkClick( event ) {
    726 			var control = this;
    727 			event.preventDefault();
    728 			control.selectMedia();
    729 		},
    730 
    731 		/**
    732 		 * Open the media select frame to chose an item.
    733 		 *
    734 		 * @return {void}
    735 		 */
    736 		selectMedia: function selectMedia() {
    737 			var control = this, selection, mediaFrame, defaultSync, mediaFrameProps, selectionModels = [];
    738 
    739 			if ( control.isSelected() && 0 !== control.model.get( 'attachment_id' ) ) {
    740 				selectionModels.push( control.selectedAttachment );
    741 			}
    742 
    743 			selection = new wp.media.model.Selection( selectionModels, { multiple: false } );
    744 
    745 			mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() );
    746 			if ( mediaFrameProps.size ) {
    747 				control.displaySettings.set( 'size', mediaFrameProps.size );
    748 			}
    749 
    750 			mediaFrame = new component.MediaFrameSelect({
    751 				title: control.l10n.add_media,
    752 				frame: 'post',
    753 				text: control.l10n.add_to_widget,
    754 				selection: selection,
    755 				mimeType: control.mime_type,
    756 				selectedDisplaySettings: control.displaySettings,
    757 				showDisplaySettings: control.showDisplaySettings,
    758 				metadata: mediaFrameProps,
    759 				state: control.isSelected() && 0 === control.model.get( 'attachment_id' ) ? 'embed' : 'insert',
    760 				invalidEmbedTypeError: control.l10n.unsupported_file_type
    761 			});
    762 			wp.media.frame = mediaFrame; // See wp.media().
    763 
    764 			// Handle selection of a media item.
    765 			mediaFrame.on( 'insert', function onInsert() {
    766 				var attachment = {}, state = mediaFrame.state();
    767 
    768 				// Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview.
    769 				if ( 'embed' === state.get( 'id' ) ) {
    770 					_.extend( attachment, { id: 0 }, state.props.toJSON() );
    771 				} else {
    772 					_.extend( attachment, state.get( 'selection' ).first().toJSON() );
    773 				}
    774 
    775 				control.selectedAttachment.set( attachment );
    776 				control.model.set( 'error', false );
    777 
    778 				// Update widget instance.
    779 				control.model.set( control.getModelPropsFromMediaFrame( mediaFrame ) );
    780 			});
    781 
    782 			// Disable syncing of attachment changes back to server (except for deletions). See <https://core.trac.wordpress.org/ticket/40403>.
    783 			defaultSync = wp.media.model.Attachment.prototype.sync;
    784 			wp.media.model.Attachment.prototype.sync = function( method ) {
    785 				if ( 'delete' === method ) {
    786 					return defaultSync.apply( this, arguments );
    787 				} else {
    788 					return $.Deferred().rejectWith( this ).promise();
    789 				}
    790 			};
    791 			mediaFrame.on( 'close', function onClose() {
    792 				wp.media.model.Attachment.prototype.sync = defaultSync;
    793 			});
    794 
    795 			mediaFrame.$el.addClass( 'media-widget' );
    796 			mediaFrame.open();
    797 
    798 			// Clear the selected attachment when it is deleted in the media select frame.
    799 			if ( selection ) {
    800 				selection.on( 'destroy', function onDestroy( attachment ) {
    801 					if ( control.model.get( 'attachment_id' ) === attachment.get( 'id' ) ) {
    802 						control.model.set({
    803 							attachment_id: 0,
    804 							url: ''
    805 						});
    806 					}
    807 				});
    808 			}
    809 
    810 			/*
    811 			 * Make sure focus is set inside of modal so that hitting Esc will close
    812 			 * the modal and not inadvertently cause the widget to collapse in the customizer.
    813 			 */
    814 			mediaFrame.$el.find( '.media-frame-menu .media-menu-item.active' ).focus();
    815 		},
    816 
    817 		/**
    818 		 * Get the instance props from the media selection frame.
    819 		 *
    820 		 * @param {wp.media.view.MediaFrame.Select} mediaFrame - Select frame.
    821 		 * @return {Object} Props.
    822 		 */
    823 		getModelPropsFromMediaFrame: function getModelPropsFromMediaFrame( mediaFrame ) {
    824 			var control = this, state, mediaFrameProps, modelProps;
    825 
    826 			state = mediaFrame.state();
    827 			if ( 'insert' === state.get( 'id' ) ) {
    828 				mediaFrameProps = state.get( 'selection' ).first().toJSON();
    829 				mediaFrameProps.postUrl = mediaFrameProps.link;
    830 
    831 				if ( control.showDisplaySettings ) {
    832 					_.extend(
    833 						mediaFrameProps,
    834 						mediaFrame.content.get( '.attachments-browser' ).sidebar.get( 'display' ).model.toJSON()
    835 					);
    836 				}
    837 				if ( mediaFrameProps.sizes && mediaFrameProps.size && mediaFrameProps.sizes[ mediaFrameProps.size ] ) {
    838 					mediaFrameProps.url = mediaFrameProps.sizes[ mediaFrameProps.size ].url;
    839 				}
    840 			} else if ( 'embed' === state.get( 'id' ) ) {
    841 				mediaFrameProps = _.extend(
    842 					state.props.toJSON(),
    843 					{ attachment_id: 0 }, // Because some media frames use `attachment_id` not `id`.
    844 					control.model.getEmbedResetProps()
    845 				);
    846 			} else {
    847 				throw new Error( 'Unexpected state: ' + state.get( 'id' ) );
    848 			}
    849 
    850 			if ( mediaFrameProps.id ) {
    851 				mediaFrameProps.attachment_id = mediaFrameProps.id;
    852 			}
    853 
    854 			modelProps = control.mapMediaToModelProps( mediaFrameProps );
    855 
    856 			// Clear the extension prop so sources will be reset for video and audio media.
    857 			_.each( wp.media.view.settings.embedExts, function( ext ) {
    858 				if ( ext in control.model.schema && modelProps.url !== modelProps[ ext ] ) {
    859 					modelProps[ ext ] = '';
    860 				}
    861 			});
    862 
    863 			return modelProps;
    864 		},
    865 
    866 		/**
    867 		 * Map media frame props to model props.
    868 		 *
    869 		 * @param {Object} mediaFrameProps - Media frame props.
    870 		 * @return {Object} Model props.
    871 		 */
    872 		mapMediaToModelProps: function mapMediaToModelProps( mediaFrameProps ) {
    873 			var control = this, mediaFramePropToModelPropMap = {}, modelProps = {}, extension;
    874 			_.each( control.model.schema, function( fieldSchema, modelProp ) {
    875 
    876 				// Ignore widget title attribute.
    877 				if ( 'title' === modelProp ) {
    878 					return;
    879 				}
    880 				mediaFramePropToModelPropMap[ fieldSchema.media_prop || modelProp ] = modelProp;
    881 			});
    882 
    883 			_.each( mediaFrameProps, function( value, mediaProp ) {
    884 				var propName = mediaFramePropToModelPropMap[ mediaProp ] || mediaProp;
    885 				if ( control.model.schema[ propName ] ) {
    886 					modelProps[ propName ] = value;
    887 				}
    888 			});
    889 
    890 			if ( 'custom' === mediaFrameProps.size ) {
    891 				modelProps.width = mediaFrameProps.customWidth;
    892 				modelProps.height = mediaFrameProps.customHeight;
    893 			}
    894 
    895 			if ( 'post' === mediaFrameProps.link ) {
    896 				modelProps.link_url = mediaFrameProps.postUrl || mediaFrameProps.linkUrl;
    897 			} else if ( 'file' === mediaFrameProps.link ) {
    898 				modelProps.link_url = mediaFrameProps.url;
    899 			}
    900 
    901 			// Because some media frames use `id` instead of `attachment_id`.
    902 			if ( ! mediaFrameProps.attachment_id && mediaFrameProps.id ) {
    903 				modelProps.attachment_id = mediaFrameProps.id;
    904 			}
    905 
    906 			if ( mediaFrameProps.url ) {
    907 				extension = mediaFrameProps.url.replace( /#.*$/, '' ).replace( /\?.*$/, '' ).split( '.' ).pop().toLowerCase();
    908 				if ( extension in control.model.schema ) {
    909 					modelProps[ extension ] = mediaFrameProps.url;
    910 				}
    911 			}
    912 
    913 			// Always omit the titles derived from mediaFrameProps.
    914 			return _.omit( modelProps, 'title' );
    915 		},
    916 
    917 		/**
    918 		 * Map model props to media frame props.
    919 		 *
    920 		 * @param {Object} modelProps - Model props.
    921 		 * @return {Object} Media frame props.
    922 		 */
    923 		mapModelToMediaFrameProps: function mapModelToMediaFrameProps( modelProps ) {
    924 			var control = this, mediaFrameProps = {};
    925 
    926 			_.each( modelProps, function( value, modelProp ) {
    927 				var fieldSchema = control.model.schema[ modelProp ] || {};
    928 				mediaFrameProps[ fieldSchema.media_prop || modelProp ] = value;
    929 			});
    930 
    931 			// Some media frames use attachment_id.
    932 			mediaFrameProps.attachment_id = mediaFrameProps.id;
    933 
    934 			if ( 'custom' === mediaFrameProps.size ) {
    935 				mediaFrameProps.customWidth = control.model.get( 'width' );
    936 				mediaFrameProps.customHeight = control.model.get( 'height' );
    937 			}
    938 
    939 			return mediaFrameProps;
    940 		},
    941 
    942 		/**
    943 		 * Map model props to previewTemplateProps.
    944 		 *
    945 		 * @return {Object} Preview Template Props.
    946 		 */
    947 		mapModelToPreviewTemplateProps: function mapModelToPreviewTemplateProps() {
    948 			var control = this, previewTemplateProps = {};
    949 			_.each( control.model.schema, function( value, prop ) {
    950 				if ( ! value.hasOwnProperty( 'should_preview_update' ) || value.should_preview_update ) {
    951 					previewTemplateProps[ prop ] = control.model.get( prop );
    952 				}
    953 			});
    954 
    955 			// Templates need to be aware of the error.
    956 			previewTemplateProps.error = control.model.get( 'error' );
    957 			return previewTemplateProps;
    958 		},
    959 
    960 		/**
    961 		 * Open the media frame to modify the selected item.
    962 		 *
    963 		 * @abstract
    964 		 * @return {void}
    965 		 */
    966 		editMedia: function editMedia() {
    967 			throw new Error( 'editMedia not implemented' );
    968 		}
    969 	});
    970 
    971 	/**
    972 	 * Media widget model.
    973 	 *
    974 	 * @class    wp.mediaWidgets.MediaWidgetModel
    975 	 * @augments Backbone.Model
    976 	 */
    977 	component.MediaWidgetModel = Backbone.Model.extend(/** @lends wp.mediaWidgets.MediaWidgetModel.prototype */{
    978 
    979 		/**
    980 		 * Id attribute.
    981 		 *
    982 		 * @type {string}
    983 		 */
    984 		idAttribute: 'widget_id',
    985 
    986 		/**
    987 		 * Instance schema.
    988 		 *
    989 		 * This adheres to JSON Schema and subclasses should have their schema
    990 		 * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts().
    991 		 *
    992 		 * @type {Object.<string, Object>}
    993 		 */
    994 		schema: {
    995 			title: {
    996 				type: 'string',
    997 				'default': ''
    998 			},
    999 			attachment_id: {
   1000 				type: 'integer',
   1001 				'default': 0
   1002 			},
   1003 			url: {
   1004 				type: 'string',
   1005 				'default': ''
   1006 			}
   1007 		},
   1008 
   1009 		/**
   1010 		 * Get default attribute values.
   1011 		 *
   1012 		 * @return {Object} Mapping of property names to their default values.
   1013 		 */
   1014 		defaults: function() {
   1015 			var defaults = {};
   1016 			_.each( this.schema, function( fieldSchema, field ) {
   1017 				defaults[ field ] = fieldSchema['default'];
   1018 			});
   1019 			return defaults;
   1020 		},
   1021 
   1022 		/**
   1023 		 * Set attribute value(s).
   1024 		 *
   1025 		 * This is a wrapped version of Backbone.Model#set() which allows us to
   1026 		 * cast the attribute values from the hidden inputs' string values into
   1027 		 * the appropriate data types (integers or booleans).
   1028 		 *
   1029 		 * @param {string|Object} key - Attribute name or attribute pairs.
   1030 		 * @param {mixed|Object}  [val] - Attribute value or options object.
   1031 		 * @param {Object}        [options] - Options when attribute name and value are passed separately.
   1032 		 * @return {wp.mediaWidgets.MediaWidgetModel} This model.
   1033 		 */
   1034 		set: function set( key, val, options ) {
   1035 			var model = this, attrs, opts, castedAttrs; // eslint-disable-line consistent-this
   1036 			if ( null === key ) {
   1037 				return model;
   1038 			}
   1039 			if ( 'object' === typeof key ) {
   1040 				attrs = key;
   1041 				opts = val;
   1042 			} else {
   1043 				attrs = {};
   1044 				attrs[ key ] = val;
   1045 				opts = options;
   1046 			}
   1047 
   1048 			castedAttrs = {};
   1049 			_.each( attrs, function( value, name ) {
   1050 				var type;
   1051 				if ( ! model.schema[ name ] ) {
   1052 					castedAttrs[ name ] = value;
   1053 					return;
   1054 				}
   1055 				type = model.schema[ name ].type;
   1056 				if ( 'array' === type ) {
   1057 					castedAttrs[ name ] = value;
   1058 					if ( ! _.isArray( castedAttrs[ name ] ) ) {
   1059 						castedAttrs[ name ] = castedAttrs[ name ].split( /,/ ); // Good enough for parsing an ID list.
   1060 					}
   1061 					if ( model.schema[ name ].items && 'integer' === model.schema[ name ].items.type ) {
   1062 						castedAttrs[ name ] = _.filter(
   1063 							_.map( castedAttrs[ name ], function( id ) {
   1064 								return parseInt( id, 10 );
   1065 							},
   1066 							function( id ) {
   1067 								return 'number' === typeof id;
   1068 							}
   1069 						) );
   1070 					}
   1071 				} else if ( 'integer' === type ) {
   1072 					castedAttrs[ name ] = parseInt( value, 10 );
   1073 				} else if ( 'boolean' === type ) {
   1074 					castedAttrs[ name ] = ! ( ! value || '0' === value || 'false' === value );
   1075 				} else {
   1076 					castedAttrs[ name ] = value;
   1077 				}
   1078 			});
   1079 
   1080 			return Backbone.Model.prototype.set.call( this, castedAttrs, opts );
   1081 		},
   1082 
   1083 		/**
   1084 		 * Get props which are merged on top of the model when an embed is chosen (as opposed to an attachment).
   1085 		 *
   1086 		 * @return {Object} Reset/override props.
   1087 		 */
   1088 		getEmbedResetProps: function getEmbedResetProps() {
   1089 			return {
   1090 				id: 0
   1091 			};
   1092 		}
   1093 	});
   1094 
   1095 	/**
   1096 	 * Collection of all widget model instances.
   1097 	 *
   1098 	 * @memberOf wp.mediaWidgets
   1099 	 *
   1100 	 * @type {Backbone.Collection}
   1101 	 */
   1102 	component.modelCollection = new ( Backbone.Collection.extend( {
   1103 		model: component.MediaWidgetModel
   1104 	}) )();
   1105 
   1106 	/**
   1107 	 * Mapping of widget ID to instances of MediaWidgetControl subclasses.
   1108 	 *
   1109 	 * @memberOf wp.mediaWidgets
   1110 	 *
   1111 	 * @type {Object.<string, wp.mediaWidgets.MediaWidgetControl>}
   1112 	 */
   1113 	component.widgetControls = {};
   1114 
   1115 	/**
   1116 	 * Handle widget being added or initialized for the first time at the widget-added event.
   1117 	 *
   1118 	 * @memberOf wp.mediaWidgets
   1119 	 *
   1120 	 * @param {jQuery.Event} event - Event.
   1121 	 * @param {jQuery}       widgetContainer - Widget container element.
   1122 	 *
   1123 	 * @return {void}
   1124 	 */
   1125 	component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) {
   1126 		var fieldContainer, syncContainer, widgetForm, idBase, ControlConstructor, ModelConstructor, modelAttributes, widgetControl, widgetModel, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone;
   1127 		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen.
   1128 		idBase = widgetForm.find( '> .id_base' ).val();
   1129 		widgetId = widgetForm.find( '> .widget-id' ).val();
   1130 
   1131 		// Prevent initializing already-added widgets.
   1132 		if ( component.widgetControls[ widgetId ] ) {
   1133 			return;
   1134 		}
   1135 
   1136 		ControlConstructor = component.controlConstructors[ idBase ];
   1137 		if ( ! ControlConstructor ) {
   1138 			return;
   1139 		}
   1140 
   1141 		ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel;
   1142 
   1143 		/*
   1144 		 * Create a container element for the widget control (Backbone.View).
   1145 		 * This is inserted into the DOM immediately before the .widget-content
   1146 		 * element because the contents of this element are essentially "managed"
   1147 		 * by PHP, where each widget update cause the entire element to be emptied
   1148 		 * and replaced with the rendered output of WP_Widget::form() which is
   1149 		 * sent back in Ajax request made to save/update the widget instance.
   1150 		 * To prevent a "flash of replaced DOM elements and re-initialized JS
   1151 		 * components", the JS template is rendered outside of the normal form
   1152 		 * container.
   1153 		 */
   1154 		fieldContainer = $( '<div></div>' );
   1155 		syncContainer = widgetContainer.find( '.widget-content:first' );
   1156 		syncContainer.before( fieldContainer );
   1157 
   1158 		/*
   1159 		 * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state.
   1160 		 * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model
   1161 		 * from the start, without having to sync with hidden fields. See <https://core.trac.wordpress.org/ticket/33507>.
   1162 		 */
   1163 		modelAttributes = {};
   1164 		syncContainer.find( '.media-widget-instance-property' ).each( function() {
   1165 			var input = $( this );
   1166 			modelAttributes[ input.data( 'property' ) ] = input.val();
   1167 		});
   1168 		modelAttributes.widget_id = widgetId;
   1169 
   1170 		widgetModel = new ModelConstructor( modelAttributes );
   1171 
   1172 		widgetControl = new ControlConstructor({
   1173 			el: fieldContainer,
   1174 			syncContainer: syncContainer,
   1175 			model: widgetModel
   1176 		});
   1177 
   1178 		/*
   1179 		 * Render the widget once the widget parent's container finishes animating,
   1180 		 * as the widget-added event fires with a slideDown of the container.
   1181 		 * This ensures that the container's dimensions are fixed so that ME.js
   1182 		 * can initialize with the proper dimensions.
   1183 		 */
   1184 		renderWhenAnimationDone = function() {
   1185 			if ( ! widgetContainer.hasClass( 'open' ) ) {
   1186 				setTimeout( renderWhenAnimationDone, animatedCheckDelay );
   1187 			} else {
   1188 				widgetControl.render();
   1189 			}
   1190 		};
   1191 		renderWhenAnimationDone();
   1192 
   1193 		/*
   1194 		 * Note that the model and control currently won't ever get garbage-collected
   1195 		 * when a widget gets removed/deleted because there is no widget-removed event.
   1196 		 */
   1197 		component.modelCollection.add( [ widgetModel ] );
   1198 		component.widgetControls[ widgetModel.get( 'widget_id' ) ] = widgetControl;
   1199 	};
   1200 
   1201 	/**
   1202 	 * Setup widget in accessibility mode.
   1203 	 *
   1204 	 * @memberOf wp.mediaWidgets
   1205 	 *
   1206 	 * @return {void}
   1207 	 */
   1208 	component.setupAccessibleMode = function setupAccessibleMode() {
   1209 		var widgetForm, widgetId, idBase, widgetControl, ControlConstructor, ModelConstructor, modelAttributes, fieldContainer, syncContainer;
   1210 		widgetForm = $( '.editwidget > form' );
   1211 		if ( 0 === widgetForm.length ) {
   1212 			return;
   1213 		}
   1214 
   1215 		idBase = widgetForm.find( '.id_base' ).val();
   1216 
   1217 		ControlConstructor = component.controlConstructors[ idBase ];
   1218 		if ( ! ControlConstructor ) {
   1219 			return;
   1220 		}
   1221 
   1222 		widgetId = widgetForm.find( '> .widget-control-actions > .widget-id' ).val();
   1223 
   1224 		ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel;
   1225 		fieldContainer = $( '<div></div>' );
   1226 		syncContainer = widgetForm.find( '> .widget-inside' );
   1227 		syncContainer.before( fieldContainer );
   1228 
   1229 		modelAttributes = {};
   1230 		syncContainer.find( '.media-widget-instance-property' ).each( function() {
   1231 			var input = $( this );
   1232 			modelAttributes[ input.data( 'property' ) ] = input.val();
   1233 		});
   1234 		modelAttributes.widget_id = widgetId;
   1235 
   1236 		widgetControl = new ControlConstructor({
   1237 			el: fieldContainer,
   1238 			syncContainer: syncContainer,
   1239 			model: new ModelConstructor( modelAttributes )
   1240 		});
   1241 
   1242 		component.modelCollection.add( [ widgetControl.model ] );
   1243 		component.widgetControls[ widgetControl.model.get( 'widget_id' ) ] = widgetControl;
   1244 
   1245 		widgetControl.render();
   1246 	};
   1247 
   1248 	/**
   1249 	 * Sync widget instance data sanitized from server back onto widget model.
   1250 	 *
   1251 	 * This gets called via the 'widget-updated' event when saving a widget from
   1252 	 * the widgets admin screen and also via the 'widget-synced' event when making
   1253 	 * a change to a widget in the customizer.
   1254 	 *
   1255 	 * @memberOf wp.mediaWidgets
   1256 	 *
   1257 	 * @param {jQuery.Event} event - Event.
   1258 	 * @param {jQuery}       widgetContainer - Widget container element.
   1259 	 *
   1260 	 * @return {void}
   1261 	 */
   1262 	component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) {
   1263 		var widgetForm, widgetContent, widgetId, widgetControl, attributes = {};
   1264 		widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' );
   1265 		widgetId = widgetForm.find( '> .widget-id' ).val();
   1266 
   1267 		widgetControl = component.widgetControls[ widgetId ];
   1268 		if ( ! widgetControl ) {
   1269 			return;
   1270 		}
   1271 
   1272 		// Make sure the server-sanitized values get synced back into the model.
   1273 		widgetContent = widgetForm.find( '> .widget-content' );
   1274 		widgetContent.find( '.media-widget-instance-property' ).each( function() {
   1275 			var property = $( this ).data( 'property' );
   1276 			attributes[ property ] = $( this ).val();
   1277 		});
   1278 
   1279 		// Suspend syncing model back to inputs when syncing from inputs to model, preventing infinite loop.
   1280 		widgetControl.stopListening( widgetControl.model, 'change', widgetControl.syncModelToInputs );
   1281 		widgetControl.model.set( attributes );
   1282 		widgetControl.listenTo( widgetControl.model, 'change', widgetControl.syncModelToInputs );
   1283 	};
   1284 
   1285 	/**
   1286 	 * Initialize functionality.
   1287 	 *
   1288 	 * This function exists to prevent the JS file from having to boot itself.
   1289 	 * When WordPress enqueues this script, it should have an inline script
   1290 	 * attached which calls wp.mediaWidgets.init().
   1291 	 *
   1292 	 * @memberOf wp.mediaWidgets
   1293 	 *
   1294 	 * @return {void}
   1295 	 */
   1296 	component.init = function init() {
   1297 		var $document = $( document );
   1298 		$document.on( 'widget-added', component.handleWidgetAdded );
   1299 		$document.on( 'widget-synced widget-updated', component.handleWidgetUpdated );
   1300 
   1301 		/*
   1302 		 * Manually trigger widget-added events for media widgets on the admin
   1303 		 * screen once they are expanded. The widget-added event is not triggered
   1304 		 * for each pre-existing widget on the widgets admin screen like it is
   1305 		 * on the customizer. Likewise, the customizer only triggers widget-added
   1306 		 * when the widget is expanded to just-in-time construct the widget form
   1307 		 * when it is actually going to be displayed. So the following implements
   1308 		 * the same for the widgets admin screen, to invoke the widget-added
   1309 		 * handler when a pre-existing media widget is expanded.
   1310 		 */
   1311 		$( function initializeExistingWidgetContainers() {
   1312 			var widgetContainers;
   1313 			if ( 'widgets' !== window.pagenow ) {
   1314 				return;
   1315 			}
   1316 			widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' );
   1317 			widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() {
   1318 				var widgetContainer = $( this );
   1319 				component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer );
   1320 			});
   1321 
   1322 			// Accessibility mode.
   1323 			if ( document.readyState === 'complete' ) {
   1324 				// Page is fully loaded.
   1325 				component.setupAccessibleMode();
   1326 			} else {
   1327 				// Page is still loading.
   1328 				$( window ).on( 'load', function() {
   1329 					component.setupAccessibleMode();
   1330 				});
   1331 			}
   1332 		});
   1333 	};
   1334 
   1335 	return component;
   1336 })( jQuery );