diff --git a/src/wp-admin/css/widgets.css b/src/wp-admin/css/widgets.css index ae28a38e7f..a73d51256e 100644 --- a/src/wp-admin/css/widgets.css +++ b/src/wp-admin/css/widgets.css @@ -55,6 +55,110 @@ color: #a0a5aa; } +/* Media Widgets */ +.wp-core-ui .media-widget-control.selected .placeholder, +.wp-core-ui .media-widget-control.selected .not-selected, +.wp-core-ui .media-widget-control .selected { + display: none; +} + +.media-widget-control.selected .selected { + display: inline-block; +} + +.media-widget-buttons { + text-align: left; + margin-bottom: 10px; +} + +.media-widget-control .media-widget-buttons .button { + margin-left: 8px; + width: auto; +} +.media-widget-control:not(.selected) .media-widget-buttons .button, +.media-widget-buttons .button:first-child { + margin-left: 0; +} + +.media-widget-control .placeholder { + border: 1px dashed #b4b9be; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + cursor: default; + line-height: 20px; + padding: 9px 0; + position: relative; + text-align: center; + width: 100%; +} + +.media-widget-control .media-widget-preview { + text-align: center; +} +.media-widget-control .media-widget-preview .notice { + text-align: initial; +} +.media-frame .media-widget-embed-notice p code, +.media-widget-control .notice p code { + padding: 0 3px 0 0; +} +.media-frame .media-widget-embed-notice { + margin-top: 16px; +} +.media-widget-control .media-widget-preview img { + max-width: 100%; +} +.media-widget-control .media-widget-preview .wp-video-shortcode { + background: #000; +} + +.media-frame.media-widget .media-toolbar-secondary { + min-width: 300px; +} + +.media-frame.media-widget .image-details .embed-media-settings .setting.align, +.media-frame.media-widget .attachment-display-settings .setting.align, +.media-frame.media-widget .embed-media-settings .setting.align, +.media-frame.media-widget .embed-link-settings .setting.link-text, +.media-frame.media-widget .replace-attachment, +.media-frame.media-widget .checkbox-setting.autoplay { + display: none; +} + +.media-widget-video-preview { + width: 100%; +} + +.media-widget-video-link { + display: inline-block; + min-height: 132px; + width: 100%; + background: black; +} + +.media-widget-video-link .dashicons { + font: normal 60px/1 'dashicons'; + position: relative; + width: 100%; + top: -90px; + color: white; + text-decoration: none; +} + +.media-widget-video-link.no-poster .dashicons { + top: 30px; +} + +.media-frame #embed-url-field.invalid { + border: 1px solid #f00; +} + +.wp-customizer .mejs-controls a:focus > .mejs-offscreen, +.widgets-php .mejs-controls a:focus > .mejs-offscreen { + z-index: 2; +} + /* Widget Dragging Helpers */ .widget.ui-draggable-dragging { min-width: 100%; diff --git a/src/wp-admin/js/widgets/media-audio-widget.js b/src/wp-admin/js/widgets/media-audio-widget.js new file mode 100644 index 0000000000..68ca64513f --- /dev/null +++ b/src/wp-admin/js/widgets/media-audio-widget.js @@ -0,0 +1,150 @@ +/* eslint consistent-this: [ "error", "control" ] */ +(function( component ) { + 'use strict'; + + var AudioWidgetModel, AudioWidgetControl, AudioDetailsMediaFrame; + + /** + * Custom audio details frame that removes the replace-audio state. + * + * @class AudioDetailsMediaFrame + * @constructor + */ + AudioDetailsMediaFrame = wp.media.view.MediaFrame.AudioDetails.extend({ + + /** + * Create the default states. + * + * @returns {void} + */ + createStates: function createStates() { + this.states.add([ + new wp.media.controller.AudioDetails( { + media: this.media + } ), + + new wp.media.controller.MediaLibrary( { + type: 'audio', + id: 'add-audio-source', + title: wp.media.view.l10n.audioAddSourceTitle, + toolbar: 'add-audio-source', + media: this.media, + menu: false + } ) + ]); + } + }); + + /** + * Audio widget model. + * + * See WP_Widget_Audio::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class AudioWidgetModel + * @constructor + */ + AudioWidgetModel = component.MediaWidgetModel.extend( {} ); + + /** + * Audio widget control. + * + * See WP_Widget_Audio::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class AudioWidgetModel + * @constructor + */ + AudioWidgetControl = component.MediaWidgetControl.extend( { + + /** + * Show display settings. + * + * @type {boolean} + */ + showDisplaySettings: false, + + /** + * Map model props to media frame props. + * + * @param {Object} modelProps - Model props. + * @returns {Object} Media frame props. + */ + mapModelToMediaFrameProps: function mapModelToMediaFrameProps( modelProps ) { + var control = this, mediaFrameProps; + mediaFrameProps = component.MediaWidgetControl.prototype.mapModelToMediaFrameProps.call( control, modelProps ); + mediaFrameProps.link = 'embed'; + return mediaFrameProps; + }, + + /** + * Render preview. + * + * @returns {void} + */ + renderPreview: function renderPreview() { + var control = this, previewContainer, previewTemplate, attachmentId, attachmentUrl; + attachmentId = control.model.get( 'attachment_id' ); + attachmentUrl = control.model.get( 'url' ); + + if ( ! attachmentId && ! attachmentUrl ) { + return; + } + + previewContainer = control.$el.find( '.media-widget-preview' ); + previewTemplate = wp.template( 'wp-media-widget-audio-preview' ); + + previewContainer.html( previewTemplate( { + model: { + attachment_id: control.model.get( 'attachment_id' ), + src: attachmentUrl + }, + error: control.model.get( 'error' ) + } ) ); + wp.mediaelement.initialize(); + }, + + /** + * Open the media audio-edit frame to modify the selected item. + * + * @returns {void} + */ + editMedia: function editMedia() { + var control = this, mediaFrame, metadata, updateCallback; + + metadata = control.mapModelToMediaFrameProps( control.model.toJSON() ); + + // Set up the media frame. + mediaFrame = new AudioDetailsMediaFrame({ + frame: 'audio', + state: 'audio-details', + metadata: metadata + } ); + wp.media.frame = mediaFrame; + mediaFrame.$el.addClass( 'media-widget' ); + + updateCallback = function( mediaFrameProps ) { + + // Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview. + control.selectedAttachment.set( mediaFrameProps ); + + control.model.set( _.extend( + control.model.defaults(), + control.mapMediaToModelProps( mediaFrameProps ), + { error: false } + ) ); + }; + + mediaFrame.state( 'audio-details' ).on( 'update', updateCallback ); + mediaFrame.state( 'replace-audio' ).on( 'replace', updateCallback ); + mediaFrame.on( 'close', function() { + mediaFrame.detach(); + }); + + mediaFrame.open(); + } + } ); + + // Exports. + component.controlConstructors.media_audio = AudioWidgetControl; + component.modelConstructors.media_audio = AudioWidgetModel; + +})( wp.mediaWidgets ); diff --git a/src/wp-admin/js/widgets/media-image-widget.js b/src/wp-admin/js/widgets/media-image-widget.js new file mode 100644 index 0000000000..c49afc3c9d --- /dev/null +++ b/src/wp-admin/js/widgets/media-image-widget.js @@ -0,0 +1,129 @@ +/* eslint consistent-this: [ "error", "control" ] */ +(function( component, $ ) { + 'use strict'; + + var ImageWidgetModel, ImageWidgetControl; + + /** + * Image widget model. + * + * See WP_Widget_Media_Image::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class ImageWidgetModel + * @constructor + */ + ImageWidgetModel = component.MediaWidgetModel.extend({}); + + /** + * Image widget control. + * + * See WP_Widget_Media_Image::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class ImageWidgetModel + * @constructor + */ + ImageWidgetControl = component.MediaWidgetControl.extend({ + + /** + * Render preview. + * + * @returns {void} + */ + renderPreview: function renderPreview() { + var control = this, previewContainer, previewTemplate; + if ( ! control.model.get( 'attachment_id' ) && ! control.model.get( 'url' ) ) { + return; + } + + previewContainer = control.$el.find( '.media-widget-preview' ); + previewTemplate = wp.template( 'wp-media-widget-image-preview' ); + previewContainer.html( previewTemplate( _.extend( control.previewTemplateProps.toJSON() ) ) ); + }, + + /** + * Open the media image-edit frame to modify the selected item. + * + * @returns {void} + */ + editMedia: function editMedia() { + var control = this, mediaFrame, updateCallback, defaultSync, metadata; + + metadata = control.mapModelToMediaFrameProps( control.model.toJSON() ); + + // Needed or else none will not be selected if linkUrl is not also empty. + if ( 'none' === metadata.link ) { + metadata.linkUrl = ''; + } + + // Set up the media frame. + mediaFrame = wp.media({ + frame: 'image', + state: 'image-details', + metadata: metadata + }); + mediaFrame.$el.addClass( 'media-widget' ); + + updateCallback = function() { + var mediaProps; + + // Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview. + mediaProps = mediaFrame.state().attributes.image.toJSON(); + control.selectedAttachment.set( mediaProps ); + + control.model.set( _.extend( + control.mapMediaToModelProps( mediaProps ), + { error: false } + ) ); + }; + + mediaFrame.state( 'image-details' ).on( 'update', updateCallback ); + mediaFrame.state( 'replace-image' ).on( 'replace', updateCallback ); + + // Disable syncing of attachment changes back to server. See . + defaultSync = wp.media.model.Attachment.prototype.sync; + wp.media.model.Attachment.prototype.sync = function rejectedSync() { + return $.Deferred().rejectWith( this ).promise(); + }; + mediaFrame.on( 'close', function onClose() { + mediaFrame.detach(); + wp.media.model.Attachment.prototype.sync = defaultSync; + }); + + mediaFrame.open(); + }, + + /** + * Get props which are merged on top of the model when an embed is chosen (as opposed to an attachment). + * + * @returns {Object} Reset/override props. + */ + getEmbedResetProps: function getEmbedResetProps() { + return _.extend( + component.MediaWidgetControl.prototype.getEmbedResetProps.call( this ), + { + size: 'full', + width: 0, + height: 0 + } + ); + }, + + /** + * Map model props to preview template props. + * + * @returns {Object} Preview template props. + */ + mapModelToPreviewTemplateProps: function mapModelToPreviewTemplateProps() { + var control = this, mediaFrameProps, url; + url = control.model.get( 'url' ); + mediaFrameProps = component.MediaWidgetControl.prototype.mapModelToPreviewTemplateProps.call( control ); + mediaFrameProps.currentFilename = url ? url.replace( /\?.*$/, '' ).replace( /^.+\//, '' ) : ''; + return mediaFrameProps; + } + }); + + // Exports. + component.controlConstructors.media_image = ImageWidgetControl; + component.modelConstructors.media_image = ImageWidgetModel; + +})( wp.mediaWidgets, jQuery ); diff --git a/src/wp-admin/js/widgets/media-video-widget.js b/src/wp-admin/js/widgets/media-video-widget.js new file mode 100644 index 0000000000..4ad17871fa --- /dev/null +++ b/src/wp-admin/js/widgets/media-video-widget.js @@ -0,0 +1,228 @@ +/* eslint consistent-this: [ "error", "control" ] */ +(function( component ) { + 'use strict'; + + var VideoWidgetModel, VideoWidgetControl, VideoDetailsMediaFrame; + + /** + * Custom video details frame that removes the replace-video state. + * + * @class VideoDetailsMediaFrame + * @constructor + */ + VideoDetailsMediaFrame = wp.media.view.MediaFrame.VideoDetails.extend({ + + /** + * Create the default states. + * + * @returns {void} + */ + createStates: function createStates() { + this.states.add([ + new wp.media.controller.VideoDetails({ + media: this.media + }), + + new wp.media.controller.MediaLibrary( { + type: 'video', + id: 'add-video-source', + title: wp.media.view.l10n.videoAddSourceTitle, + toolbar: 'add-video-source', + media: this.media, + menu: false + } ), + + new wp.media.controller.MediaLibrary( { + type: 'text', + id: 'add-track', + title: wp.media.view.l10n.videoAddTrackTitle, + toolbar: 'add-track', + media: this.media, + menu: 'video-details' + } ) + ]); + } + }); + + /** + * Video widget model. + * + * See WP_Widget_Video::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class VideoWidgetModel + * @constructor + */ + VideoWidgetModel = component.MediaWidgetModel.extend( {} ); + + /** + * Video widget control. + * + * See WP_Widget_Video::enqueue_admin_scripts() for amending prototype from PHP exports. + * + * @class VideoWidgetControl + * @constructor + */ + VideoWidgetControl = component.MediaWidgetControl.extend( { + + /** + * Show display settings. + * + * @type {boolean} + */ + showDisplaySettings: false, + + /** + * Cache of oembed responses. + * + * @type {Object} + */ + oembedResponses: {}, + + /** + * Map model props to media frame props. + * + * @param {Object} modelProps - Model props. + * @returns {Object} Media frame props. + */ + mapModelToMediaFrameProps: function mapModelToMediaFrameProps( modelProps ) { + var control = this, mediaFrameProps; + mediaFrameProps = component.MediaWidgetControl.prototype.mapModelToMediaFrameProps.call( control, modelProps ); + mediaFrameProps.link = 'embed'; + return mediaFrameProps; + }, + + /** + * Fetches embed data for external videos. + * + * @returns {void} + */ + fetchEmbed: function fetchEmbed() { + var control = this, url; + url = control.model.get( 'url' ); + + // If we already have a local cache of the embed response, return. + if ( control.oembedResponses[ url ] ) { + return; + } + + // If there is an in-flight embed request, abort it. + if ( control.fetchEmbedDfd && 'pending' === control.fetchEmbedDfd.state() ) { + control.fetchEmbedDfd.abort(); + } + + control.fetchEmbedDfd = jQuery.ajax({ + url: 'https://noembed.com/embed', + data: { + url: control.model.get( 'url' ), + maxwidth: control.model.get( 'width' ), + maxheight: control.model.get( 'height' ) + }, + type: 'GET', + crossDomain: true, + dataType: 'json' + }); + + control.fetchEmbedDfd.done( function( response ) { + control.oembedResponses[ url ] = response; + control.renderPreview(); + }); + + control.fetchEmbedDfd.fail( function() { + control.oembedResponses[ url ] = null; + }); + }, + + /** + * Render preview. + * + * @returns {void} + */ + renderPreview: function renderPreview() { + var control = this, previewContainer, previewTemplate, attachmentId, attachmentUrl, poster, isHostedEmbed = false, parsedUrl, mime, error; + attachmentId = control.model.get( 'attachment_id' ); + attachmentUrl = control.model.get( 'url' ); + error = control.model.get( 'error' ); + + if ( ! attachmentId && ! attachmentUrl ) { + return; + } + + if ( ! attachmentId && attachmentUrl ) { + parsedUrl = document.createElement( 'a' ); + parsedUrl.href = attachmentUrl; + isHostedEmbed = /vimeo|youtu\.?be/.test( parsedUrl.host ); + } + + if ( isHostedEmbed ) { + control.fetchEmbed(); + poster = control.oembedResponses[ attachmentUrl ] ? control.oembedResponses[ attachmentUrl ].thumbnail_url : null; + } + + // Verify the selected attachment mime is supported. + mime = control.selectedAttachment.get( 'mime' ); + if ( mime && attachmentId ) { + if ( ! _.contains( _.values( wp.media.view.settings.embedMimes ), mime ) ) { + error = 'unsupported_file_type'; + } + } + + previewContainer = control.$el.find( '.media-widget-preview' ); + previewTemplate = wp.template( 'wp-media-widget-video-preview' ); + + previewContainer.html( previewTemplate( { + model: { + attachment_id: control.model.get( 'attachment_id' ), + src: attachmentUrl, + poster: poster + }, + is_hosted_embed: isHostedEmbed, + error: error + } ) ); + }, + + /** + * Open the media image-edit frame to modify the selected item. + * + * @returns {void} + */ + editMedia: function editMedia() { + var control = this, mediaFrame, metadata, updateCallback; + + metadata = control.mapModelToMediaFrameProps( control.model.toJSON() ); + + // Set up the media frame. + mediaFrame = new VideoDetailsMediaFrame({ + frame: 'video', + state: 'video-details', + metadata: metadata + }); + wp.media.frame = mediaFrame; + mediaFrame.$el.addClass( 'media-widget' ); + + updateCallback = function( mediaFrameProps ) { + + // Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview. + control.selectedAttachment.set( mediaFrameProps ); + + control.model.set( _.extend( + _.omit( control.model.defaults(), 'title' ), + control.mapMediaToModelProps( mediaFrameProps ), + { error: false } + ) ); + }; + + mediaFrame.state( 'video-details' ).on( 'update', updateCallback ); + mediaFrame.state( 'replace-video' ).on( 'replace', updateCallback ); + mediaFrame.on( 'close', function() { + mediaFrame.detach(); + }); + + mediaFrame.open(); + } + } ); + + // Exports. + component.controlConstructors.media_video = VideoWidgetControl; + component.modelConstructors.media_video = VideoWidgetModel; + +})( wp.mediaWidgets ); diff --git a/src/wp-admin/js/widgets/media-widgets.js b/src/wp-admin/js/widgets/media-widgets.js new file mode 100644 index 0000000000..4ce0f3d850 --- /dev/null +++ b/src/wp-admin/js/widgets/media-widgets.js @@ -0,0 +1,1133 @@ +/* eslint consistent-this: [ "error", "control" ] */ +wp.mediaWidgets = ( function( $ ) { + 'use strict'; + + var component = {}; + + /** + * Widget control (view) constructors, mapping widget id_base to subclass of MediaWidgetControl. + * + * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base. + * + * @type {Object.} + */ + component.controlConstructors = {}; + + /** + * Widget model constructors, mapping widget id_base to subclass of MediaWidgetModel. + * + * Media widgets register themselves by assigning subclasses of MediaWidgetControl onto this object by widget ID base. + * + * @type {Object.} + */ + component.modelConstructors = {}; + + /** + * Library which persists the customized display settings across selections. + * + * @class PersistentDisplaySettingsLibrary + * @constructor + */ + component.PersistentDisplaySettingsLibrary = wp.media.controller.Library.extend({ + + /** + * Initialize. + * + * @param {Object} options - Options. + * @returns {void} + */ + initialize: function initialize( options ) { + _.bindAll( this, 'handleDisplaySettingChange' ); + wp.media.controller.Library.prototype.initialize.call( this, options ); + }, + + /** + * Sync changes to the current display settings back into the current customized. + * + * @param {Backbone.Model} displaySettings - Modified display settings. + * @returns {void} + */ + handleDisplaySettingChange: function handleDisplaySettingChange( displaySettings ) { + this.get( 'selectedDisplaySettings' ).set( displaySettings.attributes ); + }, + + /** + * Get the display settings model. + * + * Model returned is updated with the current customized display settings, + * and an event listener is added so that changes made to the settings + * will sync back into the model storing the session's customized display + * settings. + * + * @param {Backbone.Model} model - Display settings model. + * @returns {Backbone.Model} Display settings model. + */ + display: function getDisplaySettingsModel( model ) { + var display, selectedDisplaySettings = this.get( 'selectedDisplaySettings' ); + display = wp.media.controller.Library.prototype.display.call( this, model ); + + display.off( 'change', this.handleDisplaySettingChange ); // Prevent duplicated event handlers. + display.set( selectedDisplaySettings.attributes ); + if ( 'custom' === selectedDisplaySettings.get( 'link_type' ) ) { + display.linkUrl = selectedDisplaySettings.get( 'link_url' ); + } + display.on( 'change', this.handleDisplaySettingChange ); + return display; + } + }); + + /** + * Extended view for managing the embed UI. + * + * @class MediaEmbedView + * @constructor + */ + component.MediaEmbedView = wp.media.view.Embed.extend({ + + /** + * Refresh embed view. + * + * Forked override of {wp.media.view.Embed#refresh()} to suppress irrelevant "link text" field. + * + * @returns {void} + */ + refresh: function refresh() { + var Constructor; + + if ( 'image' === this.controller.options.mimeType ) { + Constructor = wp.media.view.EmbedImage; + } else { + + // This should be eliminated once #40450 lands of when this is merged into core. + Constructor = wp.media.view.EmbedLink.extend({ + + /** + * Set the disabled state on the Add to Widget button. + * + * @param {boolean} disabled - Disabled. + * @returns {void} + */ + setAddToWidgetButtonDisabled: function setAddToWidgetButtonDisabled( disabled ) { + this.views.parent.views.parent.views.get( '.media-frame-toolbar' )[0].$el.find( '.media-button-select' ).prop( 'disabled', disabled ); + }, + + /** + * Set or clear an error notice. + * + * @param {string} notice - Notice. + * @returns {void} + */ + setErrorNotice: function setErrorNotice( notice ) { + var embedLinkView = this, noticeContainer; // eslint-disable-line consistent-this + + noticeContainer = embedLinkView.views.parent.$el.find( '> .notice:first-child' ); + if ( ! notice ) { + if ( noticeContainer.length ) { + noticeContainer.slideUp( 'fast' ); + } + } else { + if ( ! noticeContainer.length ) { + noticeContainer = $( '
' ); + noticeContainer.hide(); + embedLinkView.views.parent.$el.prepend( noticeContainer ); + } + noticeContainer.empty(); + noticeContainer.append( $( '

', { + html: notice + } ) ); + noticeContainer.slideDown( 'fast' ); + } + }, + + /** + * Fetch media. + * + * This is a TEMPORARY measure until the WP API supports an oEmbed proxy endpoint. See #40450. + * + * @see https://core.trac.wordpress.org/ticket/40450 + * @returns {void} + */ + fetch: function() { + var embedLinkView = this, fetchSuccess, matches, fileExt, urlParser; // eslint-disable-line consistent-this + + if ( embedLinkView.dfd && 'pending' === embedLinkView.dfd.state() ) { + embedLinkView.dfd.abort(); + } + + fetchSuccess = function( response ) { + embedLinkView.renderoEmbed({ + data: { + body: response + } + }); + + $( '#embed-url-field' ).removeClass( 'invalid' ); + embedLinkView.setErrorNotice( '' ); + embedLinkView.setAddToWidgetButtonDisabled( false ); + }; + + urlParser = document.createElement( 'a' ); + urlParser.href = embedLinkView.model.get( 'url' ); + matches = urlParser.pathname.toLowerCase().match( /\.(\w+)$/ ); + if ( matches ) { + fileExt = matches[1]; + if ( ! wp.media.view.settings.embedMimes[ fileExt ] ) { + embedLinkView.renderFail(); + } else if ( 0 !== wp.media.view.settings.embedMimes[ fileExt ].indexOf( embedLinkView.controller.options.mimeType ) ) { + embedLinkView.renderFail(); + } else { + fetchSuccess( '' ); + } + return; + } + + embedLinkView.dfd = $.ajax({ + url: 'https://noembed.com/embed', // @todo Replace with core proxy endpoint once committed. + data: { + url: embedLinkView.model.get( 'url' ), + maxwidth: embedLinkView.model.get( 'width' ), + maxheight: embedLinkView.model.get( 'height' ) + }, + type: 'GET', + crossDomain: true, + dataType: 'json' + }); + + embedLinkView.dfd.done( function( response ) { + if ( embedLinkView.controller.options.mimeType !== response.type ) { + embedLinkView.renderFail(); + return; + } + fetchSuccess( response.html ); + }); + embedLinkView.dfd.fail( _.bind( embedLinkView.renderFail, embedLinkView ) ); + }, + + /** + * Handle render failure. + * + * Overrides the {EmbedLink#renderFail()} method to prevent showing the "Link Text" field. + * The element is getting display:none in the stylesheet, but the underlying method uses + * uses {jQuery.fn.show()} which adds an inline style. This avoids the need for !important. + * + * @returns {void} + */ + renderFail: function renderFail( ) { + var embedLinkView = this; // eslint-disable-line consistent-this + $( '#embed-url-field' ).addClass( 'invalid' ); + embedLinkView.setErrorNotice( embedLinkView.controller.options.invalidEmbedTypeError || 'ERROR' ); + embedLinkView.setAddToWidgetButtonDisabled( true ); + } + }); + } + + this.settings( new Constructor({ + controller: this.controller, + model: this.model.props, + priority: 40 + }) ); + } + }); + + /** + * Custom media frame for selecting uploaded media or providing media by URL. + * + * @class MediaFrameSelect + * @constructor + */ + component.MediaFrameSelect = wp.media.view.MediaFrame.Post.extend({ + + /** + * Create the default states. + * + * @returns {void} + */ + createStates: function createStates() { + var mime = this.options.mimeType, specificMimes = []; + _.each( wp.media.view.settings.embedMimes, function( embedMime ) { + if ( 0 === embedMime.indexOf( mime ) ) { + specificMimes.push( embedMime ); + } + }); + if ( specificMimes.length > 0 ) { + mime = specificMimes.join( ',' ); + } + + this.states.add( [ + + // Main states. + new component.PersistentDisplaySettingsLibrary({ + id: 'insert', + title: this.options.title, + selection: this.options.selection, + priority: 20, + toolbar: 'main-insert', + filterable: 'dates', + library: wp.media.query({ + type: mime + }), + multiple: false, + editable: true, + + selectedDisplaySettings: this.options.selectedDisplaySettings, + displaySettings: _.isUndefined( this.options.showDisplaySettings ) ? true : this.options.showDisplaySettings, + displayUserSettings: false // We use the display settings from the current/default widget instance props. + }), + + new wp.media.controller.EditImage({ model: this.options.editImage }), + + // Embed states. + new wp.media.controller.Embed({ + metadata: this.options.metadata, + type: 'image' === this.options.mimeType ? 'image' : 'link', + invalidEmbedTypeError: this.options.invalidEmbedTypeError + }) + ] ); + }, + + /** + * Main insert toolbar. + * + * Forked override of {wp.media.view.MediaFrame.Post#mainInsertToolbar()} to override text. + * + * @param {wp.Backbone.View} view - Toolbar view. + * @this {wp.media.controller.Library} + * @returns {void} + */ + mainInsertToolbar: function mainInsertToolbar( view ) { + var controller = this; // eslint-disable-line consistent-this + view.set( 'insert', { + style: 'primary', + priority: 80, + text: controller.options.text, // The whole reason for the fork. + requires: { selection: true }, + + /** + * Handle click. + * + * @fires wp.media.controller.State#insert() + * @returns {void} + */ + click: function onClick() { + var state = controller.state(), + selection = state.get( 'selection' ); + + controller.close(); + state.trigger( 'insert', selection ).reset(); + } + }); + }, + + /** + * Main embed toolbar. + * + * Forked override of {wp.media.view.MediaFrame.Post#mainEmbedToolbar()} to override text. + * + * @param {wp.Backbone.View} toolbar - Toolbar view. + * @this {wp.media.controller.Library} + * @returns {void} + */ + mainEmbedToolbar: function mainEmbedToolbar( toolbar ) { + toolbar.view = new wp.media.view.Toolbar.Embed({ + controller: this, + text: this.options.text, + event: 'insert' + }); + }, + + /** + * Embed content. + * + * Forked override of {wp.media.view.MediaFrame.Post#embedContent()} to suppress irrelevant "link text" field. + * + * @returns {void} + */ + embedContent: function embedContent() { + var view = new component.MediaEmbedView({ + controller: this, + model: this.state() + }).render(); + + this.content.set( view ); + + if ( ! wp.media.isTouchDevice ) { + view.url.focus(); + } + } + }); + + /** + * Media widget control. + * + * @class MediaWidgetControl + * @constructor + * @abstract + */ + component.MediaWidgetControl = Backbone.View.extend({ + + /** + * Translation strings. + * + * The mapping of translation strings is handled by media widget subclasses, + * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). + * + * @type {Object} + */ + l10n: { + add_to_widget: '{{add_to_widget}}', + add_media: '{{add_media}}' + }, + + /** + * Widget ID base. + * + * This may be defined by the subclass. It may be exported from PHP to JS + * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). If not, + * it will attempt to be discovered by looking to see if this control + * instance extends each member of component.controlConstructors, and if + * it does extend one, will use the key as the id_base. + * + * @type {string} + */ + id_base: '', + + /** + * Mime type. + * + * This must be defined by the subclass. It may be exported from PHP to JS + * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). + * + * @type {string} + */ + mime_type: '', + + /** + * View events. + * + * @type {Object} + */ + events: { + 'click .notice-missing-attachment a': 'handleMediaLibraryLinkClick', + 'click .select-media': 'selectMedia', + 'click .edit-media': 'editMedia' + }, + + /** + * Show display settings. + * + * @type {boolean} + */ + showDisplaySettings: true, + + /** + * Initialize. + * + * @param {Object} options - Options. + * @param {Backbone.Model} options.model - Model. + * @param {jQuery} options.el - Control container element. + * @returns {void} + */ + initialize: function initialize( options ) { + var control = this; + + Backbone.View.prototype.initialize.call( control, options ); + + if ( ! control.el ) { + throw new Error( 'Missing options.el' ); + } + if ( ! ( control.model instanceof component.MediaWidgetModel ) ) { + throw new Error( 'Missing options.model' ); + } + + // Allow methods to be passed in with control context preserved. + _.bindAll( control, 'syncModelToInputs', 'render', 'updateSelectedAttachment', 'renderPreview' ); + + if ( ! control.id_base ) { + _.find( component.controlConstructors, function( Constructor, idBase ) { + if ( control instanceof Constructor ) { + control.id_base = idBase; + return true; + } + return false; + }); + if ( ! control.id_base ) { + throw new Error( 'Missing id_base.' ); + } + } + + // Track attributes needed to renderPreview in it's own model. + control.previewTemplateProps = new Backbone.Model( control.mapModelToPreviewTemplateProps() ); + + // Re-render the preview when the attachment changes. + control.selectedAttachment = new wp.media.model.Attachment(); + control.renderPreview = _.debounce( control.renderPreview ); + control.listenTo( control.previewTemplateProps, 'change', control.renderPreview ); + + // Make sure a copy of the selected attachment is always fetched. + control.model.on( 'change:attachment_id', control.updateSelectedAttachment ); + control.model.on( 'change:url', control.updateSelectedAttachment ); + control.updateSelectedAttachment(); + + /* + * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state. + * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model + * from the start, without having to sync with hidden fields. See . + */ + control.listenTo( control.model, 'change', control.syncModelToInputs ); + control.listenTo( control.model, 'change', control.syncModelToPreviewProps ); + control.listenTo( control.model, 'change', control.render ); + + // Update the title. + control.$el.on( 'input', '.title', function updateTitle() { + control.model.set({ + title: $.trim( $( this ).val() ) + }); + }); + + /* + * Copy current display settings from the widget model to serve as basis + * of customized display settings for the current media frame session. + * Changes to display settings will be synced into this model, and + * when a new selection is made, the settings from this will be synced + * into that AttachmentDisplay's model to persist the setting changes. + */ + control.displaySettings = new Backbone.Model( _.pick( + control.mapModelToMediaFrameProps( + _.extend( control.model.defaults(), control.model.toJSON() ) + ), + _.keys( wp.media.view.settings.defaultProps ) + ) ); + }, + + /** + * Update the selected attachment if necessary. + * + * @returns {void} + */ + updateSelectedAttachment: function updateSelectedAttachment() { + var control = this, attachment; + + if ( 0 === control.model.get( 'attachment_id' ) ) { + control.selectedAttachment.clear(); + control.model.set( 'error', false ); + } else if ( control.model.get( 'attachment_id' ) !== control.selectedAttachment.get( 'id' ) ) { + attachment = new wp.media.model.Attachment({ + id: control.model.get( 'attachment_id' ) + }); + attachment.fetch() + .done( function done() { + control.model.set( 'error', false ); + control.selectedAttachment.set( attachment.toJSON() ); + }) + .fail( function fail() { + control.model.set( 'error', 'missing_attachment' ); + }); + } + }, + + /** + * Sync the model attributes to the hidden inputs, and update previewTemplateProps. + * + * @returns {void} + */ + syncModelToPreviewProps: function syncModelToPreviewProps() { + var control = this; + control.previewTemplateProps.set( control.mapModelToPreviewTemplateProps() ); + }, + + /** + * Sync the model attributes to the hidden inputs, and update previewTemplateProps. + * + * @returns {void} + */ + syncModelToInputs: function syncModelToInputs() { + var control = this; + control.$el.next( '.widget-content' ).find( '.media-widget-instance-property' ).each( function() { + var input = $( this ), value; + value = control.model.get( input.data( 'property' ) ); + if ( _.isUndefined( value ) ) { + return; + } + value = String( value ); + if ( input.val() === value ) { + return; + } + input.val( value ); + input.trigger( 'change' ); + }); + }, + + /** + * Get template. + * + * @returns {Function} Template. + */ + template: function template() { + var control = this; + if ( ! $( '#tmpl-widget-media-' + control.id_base + '-control' ).length ) { + throw new Error( 'Missing widget control template for ' + control.id_base ); + } + return wp.template( 'widget-media-' + control.id_base + '-control' ); + }, + + /** + * Render template. + * + * @returns {void} + */ + render: function render() { + var control = this, titleInput; + + if ( ! control.templateRendered ) { + control.$el.html( control.template()( control.model.toJSON() ) ); + control.renderPreview(); // Hereafter it will re-render when control.selectedAttachment changes. + control.templateRendered = true; + } + + titleInput = control.$el.find( '.title' ); + if ( ! titleInput.is( document.activeElement ) ) { + titleInput.val( control.model.get( 'title' ) ); + } + + control.$el.toggleClass( 'selected', control.isSelected() ); + }, + + /** + * Render media preview. + * + * @abstract + * @returns {void} + */ + renderPreview: function renderPreview() { + throw new Error( 'renderPreview must be implemented' ); + }, + + /** + * Whether a media item is selected. + * + * @returns {boolean} Whether selected and no error. + */ + isSelected: function isSelected() { + var control = this; + + if ( control.model.get( 'error' ) ) { + return false; + } + + return Boolean( control.model.get( 'attachment_id' ) || control.model.get( 'url' ) ); + }, + + /** + * Handle click on link to Media Library to open modal, such as the link that appears when in the missing attachment error notice. + * + * @param {jQuery.Event} event - Event. + * @returns {void} + */ + handleMediaLibraryLinkClick: function handleMediaLibraryLinkClick( event ) { + var control = this; + event.preventDefault(); + control.selectMedia(); + }, + + /** + * Open the media select frame to chose an item. + * + * @returns {void} + */ + selectMedia: function selectMedia() { + var control = this, selection, mediaFrame, defaultSync, mediaFrameProps; + + if ( control.isSelected() && 0 !== control.model.get( 'attachment_id' ) ) { + selection = new wp.media.model.Selection( [ control.selectedAttachment ] ); + } else { + selection = null; + } + + mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() ); + if ( mediaFrameProps.size ) { + control.displaySettings.set( 'size', mediaFrameProps.size ); + } + + mediaFrame = new component.MediaFrameSelect({ + title: control.l10n.add_media, + frame: 'post', + text: control.l10n.add_to_widget, + selection: selection, + mimeType: control.mime_type, + selectedDisplaySettings: control.displaySettings, + showDisplaySettings: control.showDisplaySettings, + metadata: mediaFrameProps, + state: control.isSelected() && 0 === control.model.get( 'attachment_id' ) ? 'embed' : 'insert', + invalidEmbedTypeError: control.l10n.unsupported_file_type + }); + wp.media.frame = mediaFrame; // See wp.media(). + + // Handle selection of a media item. + mediaFrame.on( 'insert', function onInsert() { + var attachment = {}, state = mediaFrame.state(); + + // Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview. + if ( 'embed' === state.get( 'id' ) ) { + _.extend( attachment, { id: 0 }, state.props.toJSON() ); + } else { + _.extend( attachment, state.get( 'selection' ).first().toJSON() ); + } + + control.selectedAttachment.set( attachment ); + control.model.set( 'error', false ); + + // Update widget instance. + control.model.set( control.getModelPropsFromMediaFrame( mediaFrame ) ); + }); + + // Disable syncing of attachment changes back to server. See . + defaultSync = wp.media.model.Attachment.prototype.sync; + wp.media.model.Attachment.prototype.sync = function rejectedSync() { + return $.Deferred().rejectWith( this ).promise(); + }; + mediaFrame.on( 'close', function onClose() { + wp.media.model.Attachment.prototype.sync = defaultSync; + }); + + mediaFrame.$el.addClass( 'media-widget' ); + mediaFrame.open(); + + // Clear the selected attachment when it is deleted in the media select frame. + if ( selection ) { + selection.on( 'destroy', function onDestroy( attachment ) { + if ( control.model.get( 'attachment_id' ) === attachment.get( 'id' ) ) { + control.model.set({ + attachment_id: 0, + url: '' + }); + } + }); + } + + /* + * Make sure focus is set inside of modal so that hitting Esc will close + * the modal and not inadvertently cause the widget to collapse in the customizer. + */ + mediaFrame.$el.find( '.media-frame-menu .media-menu-item.active' ).focus(); + }, + + /** + * Get the instance props from the media selection frame. + * + * @param {wp.media.view.MediaFrame.Select} mediaFrame - Select frame. + * @returns {Object} Props. + */ + getModelPropsFromMediaFrame: function getModelPropsFromMediaFrame( mediaFrame ) { + var control = this, state, mediaFrameProps, modelProps; + + state = mediaFrame.state(); + if ( 'insert' === state.get( 'id' ) ) { + mediaFrameProps = state.get( 'selection' ).first().toJSON(); + mediaFrameProps.postUrl = mediaFrameProps.link; + + if ( control.showDisplaySettings ) { + _.extend( + mediaFrameProps, + mediaFrame.content.get( '.attachments-browser' ).sidebar.get( 'display' ).model.toJSON() + ); + } + if ( mediaFrameProps.sizes && mediaFrameProps.size && mediaFrameProps.sizes[ mediaFrameProps.size ] ) { + mediaFrameProps.url = mediaFrameProps.sizes[ mediaFrameProps.size ].url; + } + } else if ( 'embed' === state.get( 'id' ) ) { + mediaFrameProps = _.extend( + state.props.toJSON(), + { attachment_id: 0 }, // Because some media frames use `attachment_id` not `id`. + control.model.getEmbedResetProps() + ); + } else { + throw new Error( 'Unexpected state: ' + state.get( 'id' ) ); + } + + if ( mediaFrameProps.id ) { + mediaFrameProps.attachment_id = mediaFrameProps.id; + } + + modelProps = control.mapMediaToModelProps( mediaFrameProps ); + + // Clear the extension prop so sources will be reset for video and audio media. + _.each( wp.media.view.settings.embedExts, function( ext ) { + if ( ext in control.model.schema && modelProps.url !== modelProps[ ext ] ) { + modelProps[ ext ] = ''; + } + } ); + + return modelProps; + }, + + /** + * Map media frame props to model props. + * + * @param {Object} mediaFrameProps - Media frame props. + * @returns {Object} Model props. + */ + mapMediaToModelProps: function mapMediaToModelProps( mediaFrameProps ) { + var control = this, mediaFramePropToModelPropMap = {}, modelProps = {}, extension; + _.each( control.model.schema, function( fieldSchema, modelProp ) { + + // Ignore widget title attribute. + if ( 'title' === modelProp ) { + return; + } + mediaFramePropToModelPropMap[ fieldSchema.media_prop || modelProp ] = modelProp; + }); + + _.each( mediaFrameProps, function( value, mediaProp ) { + var propName = mediaFramePropToModelPropMap[ mediaProp ] || mediaProp; + if ( control.model.schema[ propName ] ) { + modelProps[ propName ] = value; + } + }); + + if ( 'custom' === mediaFrameProps.size ) { + modelProps.width = mediaFrameProps.customWidth; + modelProps.height = mediaFrameProps.customHeight; + } + + if ( 'post' === mediaFrameProps.link ) { + modelProps.link_url = mediaFrameProps.postUrl; + } else if ( 'file' === mediaFrameProps.link ) { + modelProps.link_url = mediaFrameProps.url; + } + + // Because some media frames use `id` instead of `attachment_id`. + if ( ! mediaFrameProps.attachment_id && mediaFrameProps.id ) { + modelProps.attachment_id = mediaFrameProps.id; + } + + if ( mediaFrameProps.url ) { + extension = mediaFrameProps.url.replace( /#.*$/, '' ).replace( /\?.*$/, '' ).split( '.' ).pop().toLowerCase(); + if ( extension in control.model.schema ) { + modelProps[ extension ] = mediaFrameProps.url; + } + } + + // Always omit the titles derived from mediaFrameProps. + return _.omit( modelProps, 'title' ); + }, + + /** + * Map model props to media frame props. + * + * @param {Object} modelProps - Model props. + * @returns {Object} Media frame props. + */ + mapModelToMediaFrameProps: function mapModelToMediaFrameProps( modelProps ) { + var control = this, mediaFrameProps = {}; + + _.each( modelProps, function( value, modelProp ) { + var fieldSchema = control.model.schema[ modelProp ] || {}; + mediaFrameProps[ fieldSchema.media_prop || modelProp ] = value; + }); + + // Some media frames use attachment_id. + mediaFrameProps.attachment_id = mediaFrameProps.id; + + if ( 'custom' === mediaFrameProps.size ) { + mediaFrameProps.customWidth = control.model.get( 'width' ); + mediaFrameProps.customHeight = control.model.get( 'height' ); + } + + return mediaFrameProps; + }, + + /** + * Map model props to previewTemplateProps. + * + * @returns {Object} Preview Template Props. + */ + mapModelToPreviewTemplateProps: function mapModelToPreviewTemplateProps() { + var control = this, previewTemplateProps = {}; + _.each( control.model.schema, function( value, prop ) { + if ( ! value.hasOwnProperty( 'should_preview_update' ) || value.should_preview_update ) { + previewTemplateProps[ prop ] = control.model.get( prop ); + } + }); + + // Templates need to be aware of the error. + previewTemplateProps.error = control.model.get( 'error' ); + return previewTemplateProps; + }, + + /** + * Open the media frame to modify the selected item. + * + * @abstract + * @returns {void} + */ + editMedia: function editMedia() { + throw new Error( 'editMedia not implemented' ); + } + }); + + /** + * Media widget model. + * + * @class MediaWidgetModel + * @constructor + */ + component.MediaWidgetModel = Backbone.Model.extend({ + + /** + * Id attribute. + * + * @type {string} + */ + idAttribute: 'widget_id', + + /** + * Instance schema. + * + * This adheres to JSON Schema and subclasses should have their schema + * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). + * + * @type {Object.} + */ + schema: { + title: { + type: 'string', + 'default': '' + }, + attachment_id: { + type: 'integer', + 'default': 0 + }, + url: { + type: 'string', + 'default': '' + } + }, + + /** + * Get default attribute values. + * + * @returns {Object} Mapping of property names to their default values. + */ + defaults: function() { + var defaults = {}; + _.each( this.schema, function( fieldSchema, field ) { + defaults[ field ] = fieldSchema['default']; + }); + return defaults; + }, + + /** + * Set attribute value(s). + * + * This is a wrapped version of Backbone.Model#set() which allows us to + * cast the attribute values from the hidden inputs' string values into + * the appropriate data types (integers or booleans). + * + * @param {string|Object} key - Attribute name or attribute pairs. + * @param {mixed|Object} [val] - Attribute value or options object. + * @param {Object} [options] - Options when attribute name and value are passed separately. + * @returns {wp.mediaWidgets.MediaWidgetModel} This model. + */ + set: function set( key, val, options ) { + var model = this, attrs, opts, castedAttrs; // eslint-disable-line consistent-this + if ( null === key ) { + return model; + } + if ( 'object' === typeof key ) { + attrs = key; + opts = val; + } else { + attrs = {}; + attrs[ key ] = val; + opts = options; + } + + castedAttrs = {}; + _.each( attrs, function( value, name ) { + var type; + if ( ! model.schema[ name ] ) { + castedAttrs[ name ] = value; + return; + } + type = model.schema[ name ].type; + if ( 'integer' === type ) { + castedAttrs[ name ] = parseInt( value, 10 ); + } else if ( 'boolean' === type ) { + castedAttrs[ name ] = ! ( ! value || '0' === value || 'false' === value ); + } else { + castedAttrs[ name ] = value; + } + }); + + return Backbone.Model.prototype.set.call( this, castedAttrs, opts ); + }, + + /** + * Get props which are merged on top of the model when an embed is chosen (as opposed to an attachment). + * + * @returns {Object} Reset/override props. + */ + getEmbedResetProps: function getEmbedResetProps() { + return { + id: 0 + }; + } + }); + + /** + * Collection of all widget model instances. + * + * @type {Backbone.Collection} + */ + component.modelCollection = new ( Backbone.Collection.extend({ + model: component.MediaWidgetModel + }) )(); + + /** + * Mapping of widget ID to instances of MediaWidgetControl subclasses. + * + * @type {Object.} + */ + component.widgetControls = {}; + + /** + * Handle widget being added or initialized for the first time at the widget-added event. + * + * @param {jQuery.Event} event - Event. + * @param {jQuery} widgetContainer - Widget container element. + * @returns {void} + */ + component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) { + var widgetContent, controlContainer, widgetForm, idBase, ControlConstructor, ModelConstructor, modelAttributes, widgetControl, widgetModel, widgetId; + widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen. + widgetContent = widgetForm.find( '> .widget-content' ); + idBase = widgetForm.find( '> .id_base' ).val(); + widgetId = widgetForm.find( '> .widget-id' ).val(); + + // Prevent initializing already-added widgets. + if ( component.widgetControls[ widgetId ] ) { + return; + } + + ControlConstructor = component.controlConstructors[ idBase ]; + if ( ! ControlConstructor ) { + return; + } + + ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel; + + /* + * Create a container element for the widget control (Backbone.View). + * This is inserted into the DOM immediately before the the .widget-content + * element because the contents of this element are essentially "managed" + * by PHP, where each widget update cause the entire element to be emptied + * and replaced with the rendered output of WP_Widget::form() which is + * sent back in Ajax request made to save/update the widget instance. + * To prevent a "flash of replaced DOM elements and re-initialized JS + * components", the JS template is rendered outside of the normal form + * container. + */ + controlContainer = $( '

' ); + widgetContent.before( controlContainer ); + + /* + * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state. + * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model + * from the start, without having to sync with hidden fields. See . + */ + modelAttributes = {}; + widgetContent.find( '.media-widget-instance-property' ).each( function() { + var input = $( this ); + modelAttributes[ input.data( 'property' ) ] = input.val(); + }); + modelAttributes.widget_id = widgetId; + + widgetModel = new ModelConstructor( modelAttributes ); + + widgetControl = new ControlConstructor({ + el: controlContainer, + model: widgetModel + }); + widgetControl.render(); + + /* + * Note that the model and control currently won't ever get garbage-collected + * when a widget gets removed/deleted because there is no widget-removed event. + */ + component.modelCollection.add( [ widgetModel ] ); + component.widgetControls[ widgetModel.get( 'widget_id' ) ] = widgetControl; + }; + + /** + * Sync widget instance data sanitized from server back onto widget model. + * + * This gets called via the 'widget-updated' event when saving a widget from + * the widgets admin screen and also via the 'widget-synced' event when making + * a change to a widget in the customizer. + * + * @param {jQuery.Event} event - Event. + * @param {jQuery} widgetContainer - Widget container element. + * @returns {void} + */ + component.handleWidgetUpdated = function handleWidgetUpdated( event, widgetContainer ) { + var widgetForm, widgetContent, widgetId, widgetControl, attributes = {}; + widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); + widgetId = widgetForm.find( '> .widget-id' ).val(); + + widgetControl = component.widgetControls[ widgetId ]; + if ( ! widgetControl ) { + return; + } + + // Make sure the server-sanitized values get synced back into the model. + widgetContent = widgetForm.find( '> .widget-content' ); + widgetContent.find( '.media-widget-instance-property' ).each( function() { + var property = $( this ).data( 'property' ); + attributes[ property ] = $( this ).val(); + }); + + // Suspend syncing model back to inputs when syncing from inputs to model, preventing infinite loop. + widgetControl.stopListening( widgetControl.model, 'change', widgetControl.syncModelToInputs ); + widgetControl.model.set( attributes ); + widgetControl.listenTo( widgetControl.model, 'change', widgetControl.syncModelToInputs ); + }; + + /** + * Initialize functionality. + * + * This function exists to prevent the JS file from having to boot itself. + * When WordPress enqueues this script, it should have an inline script + * attached which calls wp.mediaWidgets.init(). + * + * @returns {void} + */ + component.init = function init() { + var $document = $( document ); + $document.on( 'widget-added', component.handleWidgetAdded ); + $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated ); + + /* + * Manually trigger widget-added events for media widgets on the admin + * screen once they are expanded. The widget-added event is not triggered + * for each pre-existing widget on the widgets admin screen like it is + * on the customizer. Likewise, the customizer only triggers widget-added + * when the widget is expanded to just-in-time construct the widget form + * when it is actually going to be displayed. So the following implements + * the same for the widgets admin screen, to invoke the widget-added + * handler when a pre-existing media widget is expanded. + */ + $( function initializeExistingWidgetContainers() { + var widgetContainers; + if ( 'widgets' !== window.pagenow ) { + return; + } + widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' ); + widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() { + var widgetContainer = $( this ); + component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer ); + }); + }); + }; + + return component; +})( jQuery ); diff --git a/src/wp-content/themes/twentyten/style.css b/src/wp-content/themes/twentyten/style.css index a55f323279..c42d770039 100644 --- a/src/wp-content/themes/twentyten/style.css +++ b/src/wp-content/themes/twentyten/style.css @@ -841,6 +841,9 @@ img.aligncenter { padding: 4px; text-align: center; } +.widget-container .wp-caption { + max-width: 100% !important; +} .wp-caption img { margin: 5px 5px 0; max-width: 622px; /* caption width - 10px */ diff --git a/src/wp-includes/default-widgets.php b/src/wp-includes/default-widgets.php index 0cf5fc336b..87ad9dbfb3 100644 --- a/src/wp-includes/default-widgets.php +++ b/src/wp-includes/default-widgets.php @@ -19,6 +19,21 @@ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-search.php' ); /** WP_Widget_Archives class */ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-archives.php' ); +/** WP_Widget_Media class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media.php' ); + +/** WP_Widget_Media_Audio class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-audio.php' ); + +/** WP_Widget_Media_Image class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-image.php' ); + +/** WP_Widget_Media_Video class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-media-video.php' ); + +/** WP_Widget_Meta class */ +require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-meta.php' ); + /** WP_Widget_Meta class */ require_once( ABSPATH . WPINC . '/widgets/class-wp-widget-meta.php' ); diff --git a/src/wp-includes/js/customize-selective-refresh.js b/src/wp-includes/js/customize-selective-refresh.js index 4f7165656a..ee96d295df 100644 --- a/src/wp-includes/js/customize-selective-refresh.js +++ b/src/wp-includes/js/customize-selective-refresh.js @@ -469,6 +469,15 @@ wp.customize.selectiveRefresh = ( function( $, api ) { // Prevent placement container from being being re-triggered as being rendered among nested partials. placement.container.data( 'customize-partial-content-rendered', true ); + /* + * Note that the 'wp_audio_shortcode_library' and 'wp_video_shortcode_library' filters + * will determine whether or not wp.mediaelement is loaded and whether it will + * initialize audio and video respectively. See also https://core.trac.wordpress.org/ticket/40144 + */ + if ( wp.mediaelement ) { + wp.mediaelement.initialize(); + } + /** * Announce when a partial's placement has been rendered so that dynamic elements can be re-built. */ diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index e2504e99b5..efab0d50ea 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -608,7 +608,7 @@ function wp_print_media_templates() {

<# if ( 'image' === data.type ) { #> -