From da32c2f6301a5083a994d7ff2bba36e6e50bcea0 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 May 2017 21:10:54 +0000 Subject: [PATCH] Widgets: Introduce media widgets for images, audio, and video with extensible base for additional media widgets in the future. The last time a new widget was introduced, Vuvuzelas were a thing, Angry Birds started taking over phones, and WordPress stopped shipping with Kubrick. Seven years and 17 releases without new widgets have been enough, time to spice up your sidebar! Props westonruter, melchoyce, obenland, timmydcrawford, adamsilverstein, gonom9, wonderboymusic, Fab1en, DrewAPicture, sirbrillig, joen, matias, samikeijonen, afercia, celloexpressions, designsimply, michelleweber, ranh, kjellr, karmatosed. Fixes #32417, #39993, #39994, #39995. git-svn-id: https://develop.svn.wordpress.org/trunk@40640 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/widgets.css | 104 ++ src/wp-admin/js/widgets/media-audio-widget.js | 150 ++ src/wp-admin/js/widgets/media-image-widget.js | 129 ++ src/wp-admin/js/widgets/media-video-widget.js | 228 +++ src/wp-admin/js/widgets/media-widgets.js | 1133 ++++++++++++ src/wp-content/themes/twentyten/style.css | 3 + src/wp-includes/default-widgets.php | 15 + .../js/customize-selective-refresh.js | 9 + src/wp-includes/media-template.php | 6 +- src/wp-includes/script-loader.php | 6 + src/wp-includes/widgets.php | 38 +- .../widgets/class-wp-widget-media-audio.php | 204 +++ .../widgets/class-wp-widget-media-image.php | 326 ++++ .../widgets/class-wp-widget-media-video.php | 255 +++ .../widgets/class-wp-widget-media.php | 422 +++++ tests/phpunit/data/images/canola-150x150.jpg | Bin 0 -> 4277 bytes tests/phpunit/data/images/canola-300x225.jpg | Bin 0 -> 12314 bytes tests/phpunit/data/uploads/small-audio.mp3 | Bin 0 -> 45877 bytes tests/phpunit/data/uploads/small-video.mp4 | Bin 0 -> 383631 bytes .../tests/widgets/media-audio-widget.php | 266 +++ .../tests/widgets/media-image-widget.php | 480 +++++ .../tests/widgets/media-video-widget.php | 292 ++++ tests/phpunit/tests/widgets/media-widget.php | 467 +++++ tests/phpunit/tests/widgets/text-widget.php | 4 +- tests/qunit/index.html | 1545 +++++++++++++++++ .../js/widgets/test-media-image-widget.js | 113 ++ .../js/widgets/test-media-video-widget.js | 68 + .../wp-admin/js/widgets/test-media-widgets.js | 45 + 28 files changed, 6288 insertions(+), 20 deletions(-) create mode 100644 src/wp-admin/js/widgets/media-audio-widget.js create mode 100644 src/wp-admin/js/widgets/media-image-widget.js create mode 100644 src/wp-admin/js/widgets/media-video-widget.js create mode 100644 src/wp-admin/js/widgets/media-widgets.js create mode 100644 src/wp-includes/widgets/class-wp-widget-media-audio.php create mode 100644 src/wp-includes/widgets/class-wp-widget-media-image.php create mode 100644 src/wp-includes/widgets/class-wp-widget-media-video.php create mode 100644 src/wp-includes/widgets/class-wp-widget-media.php create mode 100644 tests/phpunit/data/images/canola-150x150.jpg create mode 100644 tests/phpunit/data/images/canola-300x225.jpg create mode 100644 tests/phpunit/data/uploads/small-audio.mp3 create mode 100644 tests/phpunit/data/uploads/small-video.mp4 create mode 100644 tests/phpunit/tests/widgets/media-audio-widget.php create mode 100644 tests/phpunit/tests/widgets/media-image-widget.php create mode 100644 tests/phpunit/tests/widgets/media-video-widget.php create mode 100644 tests/phpunit/tests/widgets/media-widget.php create mode 100644 tests/qunit/wp-admin/js/widgets/test-media-image-widget.js create mode 100644 tests/qunit/wp-admin/js/widgets/test-media-video-widget.js create mode 100644 tests/qunit/wp-admin/js/widgets/test-media-widgets.js 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 ) { #> -