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 */
11wp.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" role="alert"></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 );
1337