/** * @output wp-admin/js/widgets/media-widgets.js */ /* eslint consistent-this: [ "error", "control" ] */ /** * @namespace wp.mediaWidgets * @memberOf wp */ 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. * * @memberOf wp.mediaWidgets * * @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. * * @memberOf wp.mediaWidgets * * @type {Object.} */ component.modelConstructors = {}; component.PersistentDisplaySettingsLibrary = wp.media.controller.Library.extend(/** @lends wp.mediaWidgets.PersistentDisplaySettingsLibrary.prototype */{ /** * Library which persists the customized display settings across selections. * * @constructs wp.mediaWidgets.PersistentDisplaySettingsLibrary * @augments wp.media.controller.Library * * @param {Object} options - Options. * * @return {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. * @return {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. * @return {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 wp.mediaWidgets.MediaEmbedView * @augments wp.media.view.Embed */ component.MediaEmbedView = wp.media.view.Embed.extend(/** @lends wp.mediaWidgets.MediaEmbedView.prototype */{ /** * Initialize. * * @since 4.9.0 * * @param {Object} options - Options. * @return {void} */ initialize: function( options ) { var view = this, embedController; // eslint-disable-line consistent-this wp.media.view.Embed.prototype.initialize.call( view, options ); if ( 'image' !== view.controller.options.mimeType ) { embedController = view.controller.states.get( 'embed' ); embedController.off( 'scan', embedController.scanImage, embedController ); } }, /** * Refresh embed view. * * Forked override of {wp.media.view.Embed#refresh()} to suppress irrelevant "link text" field. * * @return {void} */ refresh: function refresh() { /** * @class wp.mediaWidgets~Constructor */ 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(/** @lends wp.mediaWidgets~Constructor.prototype */{ /** * Set the disabled state on the Add to Widget button. * * @param {boolean} disabled - Disabled. * @return {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. * @return {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' ); } }, /** * Update oEmbed. * * @since 4.9.0 * * @return {void} */ updateoEmbed: function() { var embedLinkView = this, url; // eslint-disable-line consistent-this url = embedLinkView.model.get( 'url' ); // Abort if the URL field was emptied out. if ( ! url ) { embedLinkView.setErrorNotice( '' ); embedLinkView.setAddToWidgetButtonDisabled( true ); return; } if ( ! url.match( /^(http|https):\/\/.+\// ) ) { embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' ); embedLinkView.setAddToWidgetButtonDisabled( true ); } wp.media.view.EmbedLink.prototype.updateoEmbed.call( embedLinkView ); }, /** * Fetch media. * * @return {void} */ fetch: function() { var embedLinkView = this, fetchSuccess, matches, fileExt, urlParser, url, re, youTubeEmbedMatch; // eslint-disable-line consistent-this url = embedLinkView.model.get( 'url' ); if ( embedLinkView.dfd && 'pending' === embedLinkView.dfd.state() ) { embedLinkView.dfd.abort(); } fetchSuccess = function( response ) { embedLinkView.renderoEmbed({ data: { body: response } }); embedLinkView.controller.$el.find( '#embed-url-field' ).removeClass( 'invalid' ); embedLinkView.setErrorNotice( '' ); embedLinkView.setAddToWidgetButtonDisabled( false ); }; urlParser = document.createElement( 'a' ); urlParser.href = url; matches = urlParser.pathname.toLowerCase().match( /\.(\w+)$/ ); if ( matches ) { fileExt = matches[1]; if ( ! wp.media.view.settings.embedMimes[ fileExt ] ) { embedLinkView.renderFail(); } else if ( 0 !== wp.media.view.settings.embedMimes[ fileExt ].indexOf( embedLinkView.controller.options.mimeType ) ) { embedLinkView.renderFail(); } else { fetchSuccess( '' ); } return; } // Support YouTube embed links. re = /https?:\/\/www\.youtube\.com\/embed\/([^/]+)/; youTubeEmbedMatch = re.exec( url ); if ( youTubeEmbedMatch ) { url = 'https://www.youtube.com/watch?v=' + youTubeEmbedMatch[ 1 ]; // silently change url to proper oembed-able version. embedLinkView.model.attributes.url = url; } embedLinkView.dfd = wp.apiRequest({ url: wp.media.view.settings.oEmbedProxyUrl, data: { url: url, maxwidth: embedLinkView.model.get( 'width' ), maxheight: embedLinkView.model.get( 'height' ), discover: false }, type: 'GET', dataType: 'json', context: embedLinkView }); embedLinkView.dfd.done( function( response ) { if ( embedLinkView.controller.options.mimeType !== response.type ) { embedLinkView.renderFail(); return; } fetchSuccess( response.html ); }); embedLinkView.dfd.fail( _.bind( embedLinkView.renderFail, embedLinkView ) ); }, /** * Handle render failure. * * Overrides the {EmbedLink#renderFail()} method to prevent showing the "Link Text" field. * The element is getting display:none in the stylesheet, but the underlying method uses * uses {jQuery.fn.show()} which adds an inline style. This avoids the need for !important. * * @return {void} */ renderFail: function renderFail() { var embedLinkView = this; // eslint-disable-line consistent-this embedLinkView.controller.$el.find( '#embed-url-field' ).addClass( 'invalid' ); embedLinkView.setErrorNotice( embedLinkView.controller.options.invalidEmbedTypeError || 'ERROR' ); embedLinkView.setAddToWidgetButtonDisabled( true ); } }); } this.settings( new Constructor({ controller: this.controller, model: this.model.props, priority: 40 })); } }); /** * Custom media frame for selecting uploaded media or providing media by URL. * * @class wp.mediaWidgets.MediaFrameSelect * @augments wp.media.view.MediaFrame.Post */ component.MediaFrameSelect = wp.media.view.MediaFrame.Post.extend(/** @lends wp.mediaWidgets.MediaFrameSelect.prototype */{ /** * Create the default states. * * @return {void} */ createStates: function createStates() { var mime = this.options.mimeType, specificMimes = []; _.each( wp.media.view.settings.embedMimes, function( embedMime ) { if ( 0 === embedMime.indexOf( mime ) ) { specificMimes.push( embedMime ); } }); if ( specificMimes.length > 0 ) { mime = specificMimes; } this.states.add([ // Main states. new component.PersistentDisplaySettingsLibrary({ id: 'insert', title: this.options.title, selection: this.options.selection, priority: 20, toolbar: 'main-insert', filterable: 'dates', library: wp.media.query({ type: mime }), multiple: false, editable: true, selectedDisplaySettings: this.options.selectedDisplaySettings, displaySettings: _.isUndefined( this.options.showDisplaySettings ) ? true : this.options.showDisplaySettings, displayUserSettings: false // We use the display settings from the current/default widget instance props. }), new wp.media.controller.EditImage({ model: this.options.editImage }), // Embed states. new wp.media.controller.Embed({ metadata: this.options.metadata, type: 'image' === this.options.mimeType ? 'image' : 'link', invalidEmbedTypeError: this.options.invalidEmbedTypeError }) ]); }, /** * Main insert toolbar. * * Forked override of {wp.media.view.MediaFrame.Post#mainInsertToolbar()} to override text. * * @param {wp.Backbone.View} view - Toolbar view. * @this {wp.media.controller.Library} * @return {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. * * @ignore * * @fires wp.media.controller.State#insert() * @return {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} * @return {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. * * @return {void} */ embedContent: function embedContent() { var view = new component.MediaEmbedView({ controller: this, model: this.state() }).render(); this.content.set( view ); } }); component.MediaWidgetControl = Backbone.View.extend(/** @lends wp.mediaWidgets.MediaWidgetControl.prototype */{ /** * Translation strings. * * The mapping of translation strings is handled by media widget subclasses, * exported from PHP to JS such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). * * @type {Object} */ l10n: { add_to_widget: '{{add_to_widget}}', add_media: '{{add_media}}' }, /** * Widget ID base. * * This may be defined by the subclass. It may be exported from PHP to JS * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). If not, * it will attempt to be discovered by looking to see if this control * instance extends each member of component.controlConstructors, and if * it does extend one, will use the key as the id_base. * * @type {string} */ id_base: '', /** * Mime type. * * This must be defined by the subclass. It may be exported from PHP to JS * such as is done in WP_Widget_Media_Image::enqueue_admin_scripts(). * * @type {string} */ mime_type: '', /** * View events. * * @type {Object} */ events: { 'click .notice-missing-attachment a': 'handleMediaLibraryLinkClick', 'click .select-media': 'selectMedia', 'click .placeholder': 'selectMedia', 'click .edit-media': 'editMedia' }, /** * Show display settings. * * @type {boolean} */ showDisplaySettings: true, /** * Media Widget Control. * * @constructs wp.mediaWidgets.MediaWidgetControl * @augments Backbone.View * @abstract * * @param {Object} options - Options. * @param {Backbone.Model} options.model - Model. * @param {jQuery} options.el - Control field container element. * @param {jQuery} options.syncContainer - Container element where fields are synced for the server. * * @return {void} */ initialize: function initialize( options ) { var control = this; Backbone.View.prototype.initialize.call( control, options ); if ( ! ( control.model instanceof component.MediaWidgetModel ) ) { throw new Error( 'Missing options.model' ); } if ( ! options.el ) { throw new Error( 'Missing options.el' ); } if ( ! options.syncContainer ) { throw new Error( 'Missing options.syncContainer' ); } control.syncContainer = options.syncContainer; control.$el.addClass( 'media-widget-control' ); // Allow methods to be passed in with control context preserved. _.bindAll( control, 'syncModelToInputs', 'render', 'updateSelectedAttachment', 'renderPreview' ); if ( ! control.id_base ) { _.find( component.controlConstructors, function( Constructor, idBase ) { if ( control instanceof Constructor ) { control.id_base = idBase; return true; } return false; }); if ( ! control.id_base ) { throw new Error( 'Missing id_base.' ); } } // Track attributes needed to renderPreview in it's own model. control.previewTemplateProps = new Backbone.Model( control.mapModelToPreviewTemplateProps() ); // Re-render the preview when the attachment changes. control.selectedAttachment = new wp.media.model.Attachment(); control.renderPreview = _.debounce( control.renderPreview ); control.listenTo( control.previewTemplateProps, 'change', control.renderPreview ); // Make sure a copy of the selected attachment is always fetched. control.model.on( 'change:attachment_id', control.updateSelectedAttachment ); control.model.on( 'change:url', control.updateSelectedAttachment ); control.updateSelectedAttachment(); /* * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state. * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model * from the start, without having to sync with hidden fields. See . */ control.listenTo( control.model, 'change', control.syncModelToInputs ); control.listenTo( control.model, 'change', control.syncModelToPreviewProps ); control.listenTo( control.model, 'change', control.render ); // Update the title. control.$el.on( 'input change', '.title', function updateTitle() { control.model.set({ title: $( this ).val().trim() }); }); // Update link_url attribute. control.$el.on( 'input change', '.link', function updateLinkUrl() { var linkUrl = $( this ).val().trim(), linkType = 'custom'; if ( control.selectedAttachment.get( 'linkUrl' ) === linkUrl || control.selectedAttachment.get( 'link' ) === linkUrl ) { linkType = 'post'; } else if ( control.selectedAttachment.get( 'url' ) === linkUrl ) { linkType = 'file'; } control.model.set( { link_url: linkUrl, link_type: linkType }); // Update display settings for the next time the user opens to select from the media library. control.displaySettings.set( { link: linkType, linkUrl: linkUrl }); }); /* * Copy current display settings from the widget model to serve as basis * of customized display settings for the current media frame session. * Changes to display settings will be synced into this model, and * when a new selection is made, the settings from this will be synced * into that AttachmentDisplay's model to persist the setting changes. */ control.displaySettings = new Backbone.Model( _.pick( control.mapModelToMediaFrameProps( _.extend( control.model.defaults(), control.model.toJSON() ) ), _.keys( wp.media.view.settings.defaultProps ) ) ); }, /** * Update the selected attachment if necessary. * * @return {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. * * @return {void} */ syncModelToPreviewProps: function syncModelToPreviewProps() { var control = this; control.previewTemplateProps.set( control.mapModelToPreviewTemplateProps() ); }, /** * Sync the model attributes to the hidden inputs, and update previewTemplateProps. * * @return {void} */ syncModelToInputs: function syncModelToInputs() { var control = this; control.syncContainer.find( '.media-widget-instance-property' ).each( function() { var input = $( this ), value, propertyName; propertyName = input.data( 'property' ); value = control.model.get( propertyName ); if ( _.isUndefined( value ) ) { return; } if ( 'array' === control.model.schema[ propertyName ].type && _.isArray( value ) ) { value = value.join( ',' ); } else if ( 'boolean' === control.model.schema[ propertyName ].type ) { value = value ? '1' : ''; // Because in PHP, strval( true ) === '1' && strval( false ) === ''. } else { value = String( value ); } if ( input.val() !== value ) { input.val( value ); input.trigger( 'change' ); } }); }, /** * Get template. * * @return {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. * * @return {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 * @return {void} */ renderPreview: function renderPreview() { throw new Error( 'renderPreview must be implemented' ); }, /** * Whether a media item is selected. * * @return {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. * @return {void} */ handleMediaLibraryLinkClick: function handleMediaLibraryLinkClick( event ) { var control = this; event.preventDefault(); control.selectMedia(); }, /** * Open the media select frame to chose an item. * * @return {void} */ selectMedia: function selectMedia() { var control = this, selection, mediaFrame, defaultSync, mediaFrameProps, selectionModels = []; if ( control.isSelected() && 0 !== control.model.get( 'attachment_id' ) ) { selectionModels.push( control.selectedAttachment ); } selection = new wp.media.model.Selection( selectionModels, { multiple: false } ); mediaFrameProps = control.mapModelToMediaFrameProps( control.model.toJSON() ); if ( mediaFrameProps.size ) { control.displaySettings.set( 'size', mediaFrameProps.size ); } mediaFrame = new component.MediaFrameSelect({ title: control.l10n.add_media, frame: 'post', text: control.l10n.add_to_widget, selection: selection, mimeType: control.mime_type, selectedDisplaySettings: control.displaySettings, showDisplaySettings: control.showDisplaySettings, metadata: mediaFrameProps, state: control.isSelected() && 0 === control.model.get( 'attachment_id' ) ? 'embed' : 'insert', invalidEmbedTypeError: control.l10n.unsupported_file_type }); wp.media.frame = mediaFrame; // See wp.media(). // Handle selection of a media item. mediaFrame.on( 'insert', function onInsert() { var attachment = {}, state = mediaFrame.state(); // Update cached attachment object to avoid having to re-fetch. This also triggers re-rendering of preview. if ( 'embed' === state.get( 'id' ) ) { _.extend( attachment, { id: 0 }, state.props.toJSON() ); } else { _.extend( attachment, state.get( 'selection' ).first().toJSON() ); } control.selectedAttachment.set( attachment ); control.model.set( 'error', false ); // Update widget instance. control.model.set( control.getModelPropsFromMediaFrame( mediaFrame ) ); }); // Disable syncing of attachment changes back to server (except for deletions). See . defaultSync = wp.media.model.Attachment.prototype.sync; wp.media.model.Attachment.prototype.sync = function( method ) { if ( 'delete' === method ) { return defaultSync.apply( this, arguments ); } else { return $.Deferred().rejectWith( this ).promise(); } }; mediaFrame.on( 'close', function onClose() { wp.media.model.Attachment.prototype.sync = defaultSync; }); mediaFrame.$el.addClass( 'media-widget' ); mediaFrame.open(); // Clear the selected attachment when it is deleted in the media select frame. if ( selection ) { selection.on( 'destroy', function onDestroy( attachment ) { if ( control.model.get( 'attachment_id' ) === attachment.get( 'id' ) ) { control.model.set({ attachment_id: 0, url: '' }); } }); } /* * Make sure focus is set inside of modal so that hitting Esc will close * the modal and not inadvertently cause the widget to collapse in the customizer. */ mediaFrame.$el.find( '.media-frame-menu .media-menu-item.active' ).focus(); }, /** * Get the instance props from the media selection frame. * * @param {wp.media.view.MediaFrame.Select} mediaFrame - Select frame. * @return {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. * @return {Object} Model props. */ mapMediaToModelProps: function mapMediaToModelProps( mediaFrameProps ) { var control = this, mediaFramePropToModelPropMap = {}, modelProps = {}, extension; _.each( control.model.schema, function( fieldSchema, modelProp ) { // Ignore widget title attribute. if ( 'title' === modelProp ) { return; } mediaFramePropToModelPropMap[ fieldSchema.media_prop || modelProp ] = modelProp; }); _.each( mediaFrameProps, function( value, mediaProp ) { var propName = mediaFramePropToModelPropMap[ mediaProp ] || mediaProp; if ( control.model.schema[ propName ] ) { modelProps[ propName ] = value; } }); if ( 'custom' === mediaFrameProps.size ) { modelProps.width = mediaFrameProps.customWidth; modelProps.height = mediaFrameProps.customHeight; } if ( 'post' === mediaFrameProps.link ) { modelProps.link_url = mediaFrameProps.postUrl || mediaFrameProps.linkUrl; } else if ( 'file' === mediaFrameProps.link ) { modelProps.link_url = mediaFrameProps.url; } // Because some media frames use `id` instead of `attachment_id`. if ( ! mediaFrameProps.attachment_id && mediaFrameProps.id ) { modelProps.attachment_id = mediaFrameProps.id; } if ( mediaFrameProps.url ) { extension = mediaFrameProps.url.replace( /#.*$/, '' ).replace( /\?.*$/, '' ).split( '.' ).pop().toLowerCase(); if ( extension in control.model.schema ) { modelProps[ extension ] = mediaFrameProps.url; } } // Always omit the titles derived from mediaFrameProps. return _.omit( modelProps, 'title' ); }, /** * Map model props to media frame props. * * @param {Object} modelProps - Model props. * @return {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. * * @return {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 * @return {void} */ editMedia: function editMedia() { throw new Error( 'editMedia not implemented' ); } }); /** * Media widget model. * * @class wp.mediaWidgets.MediaWidgetModel * @augments Backbone.Model */ component.MediaWidgetModel = Backbone.Model.extend(/** @lends wp.mediaWidgets.MediaWidgetModel.prototype */{ /** * 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. * * @return {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. * @return {wp.mediaWidgets.MediaWidgetModel} This model. */ set: function set( key, val, options ) { var model = this, attrs, opts, castedAttrs; // eslint-disable-line consistent-this if ( null === key ) { return model; } if ( 'object' === typeof key ) { attrs = key; opts = val; } else { attrs = {}; attrs[ key ] = val; opts = options; } castedAttrs = {}; _.each( attrs, function( value, name ) { var type; if ( ! model.schema[ name ] ) { castedAttrs[ name ] = value; return; } type = model.schema[ name ].type; if ( 'array' === type ) { castedAttrs[ name ] = value; if ( ! _.isArray( castedAttrs[ name ] ) ) { castedAttrs[ name ] = castedAttrs[ name ].split( /,/ ); // Good enough for parsing an ID list. } if ( model.schema[ name ].items && 'integer' === model.schema[ name ].items.type ) { castedAttrs[ name ] = _.filter( _.map( castedAttrs[ name ], function( id ) { return parseInt( id, 10 ); }, function( id ) { return 'number' === typeof id; } ) ); } } else if ( 'integer' === type ) { castedAttrs[ name ] = parseInt( value, 10 ); } else if ( 'boolean' === type ) { castedAttrs[ name ] = ! ( ! value || '0' === value || 'false' === value ); } else { castedAttrs[ name ] = value; } }); return Backbone.Model.prototype.set.call( this, castedAttrs, opts ); }, /** * Get props which are merged on top of the model when an embed is chosen (as opposed to an attachment). * * @return {Object} Reset/override props. */ getEmbedResetProps: function getEmbedResetProps() { return { id: 0 }; } }); /** * Collection of all widget model instances. * * @memberOf wp.mediaWidgets * * @type {Backbone.Collection} */ component.modelCollection = new ( Backbone.Collection.extend( { model: component.MediaWidgetModel }) )(); /** * Mapping of widget ID to instances of MediaWidgetControl subclasses. * * @memberOf wp.mediaWidgets * * @type {Object.} */ component.widgetControls = {}; /** * Handle widget being added or initialized for the first time at the widget-added event. * * @memberOf wp.mediaWidgets * * @param {jQuery.Event} event - Event. * @param {jQuery} widgetContainer - Widget container element. * * @return {void} */ component.handleWidgetAdded = function handleWidgetAdded( event, widgetContainer ) { var fieldContainer, syncContainer, widgetForm, idBase, ControlConstructor, ModelConstructor, modelAttributes, widgetControl, widgetModel, widgetId, animatedCheckDelay = 50, renderWhenAnimationDone; widgetForm = widgetContainer.find( '> .widget-inside > .form, > .widget-inside > form' ); // Note: '.form' appears in the customizer, whereas 'form' on the widgets admin screen. idBase = widgetForm.find( '> .id_base' ).val(); widgetId = widgetForm.find( '> .widget-id' ).val(); // Prevent initializing already-added widgets. if ( component.widgetControls[ widgetId ] ) { return; } ControlConstructor = component.controlConstructors[ idBase ]; if ( ! ControlConstructor ) { return; } ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel; /* * Create a container element for the widget control (Backbone.View). * This is inserted into the DOM immediately before the .widget-content * element because the contents of this element are essentially "managed" * by PHP, where each widget update cause the entire element to be emptied * and replaced with the rendered output of WP_Widget::form() which is * sent back in Ajax request made to save/update the widget instance. * To prevent a "flash of replaced DOM elements and re-initialized JS * components", the JS template is rendered outside of the normal form * container. */ fieldContainer = $( '

' ); syncContainer = widgetContainer.find( '.widget-content:first' ); syncContainer.before( fieldContainer ); /* * Sync the widget instance model attributes onto the hidden inputs that widgets currently use to store the state. * In the future, when widgets are JS-driven, the underlying widget instance data should be exposed as a model * from the start, without having to sync with hidden fields. See . */ modelAttributes = {}; syncContainer.find( '.media-widget-instance-property' ).each( function() { var input = $( this ); modelAttributes[ input.data( 'property' ) ] = input.val(); }); modelAttributes.widget_id = widgetId; widgetModel = new ModelConstructor( modelAttributes ); widgetControl = new ControlConstructor({ el: fieldContainer, syncContainer: syncContainer, model: widgetModel }); /* * Render the widget once the widget parent's container finishes animating, * as the widget-added event fires with a slideDown of the container. * This ensures that the container's dimensions are fixed so that ME.js * can initialize with the proper dimensions. */ renderWhenAnimationDone = function() { if ( ! widgetContainer.hasClass( 'open' ) ) { setTimeout( renderWhenAnimationDone, animatedCheckDelay ); } else { widgetControl.render(); } }; renderWhenAnimationDone(); /* * Note that the model and control currently won't ever get garbage-collected * when a widget gets removed/deleted because there is no widget-removed event. */ component.modelCollection.add( [ widgetModel ] ); component.widgetControls[ widgetModel.get( 'widget_id' ) ] = widgetControl; }; /** * Setup widget in accessibility mode. * * @memberOf wp.mediaWidgets * * @return {void} */ component.setupAccessibleMode = function setupAccessibleMode() { var widgetForm, widgetId, idBase, widgetControl, ControlConstructor, ModelConstructor, modelAttributes, fieldContainer, syncContainer; widgetForm = $( '.editwidget > form' ); if ( 0 === widgetForm.length ) { return; } idBase = widgetForm.find( '.id_base' ).val(); ControlConstructor = component.controlConstructors[ idBase ]; if ( ! ControlConstructor ) { return; } widgetId = widgetForm.find( '> .widget-control-actions > .widget-id' ).val(); ModelConstructor = component.modelConstructors[ idBase ] || component.MediaWidgetModel; fieldContainer = $( '
' ); syncContainer = widgetForm.find( '> .widget-inside' ); syncContainer.before( fieldContainer ); modelAttributes = {}; syncContainer.find( '.media-widget-instance-property' ).each( function() { var input = $( this ); modelAttributes[ input.data( 'property' ) ] = input.val(); }); modelAttributes.widget_id = widgetId; widgetControl = new ControlConstructor({ el: fieldContainer, syncContainer: syncContainer, model: new ModelConstructor( modelAttributes ) }); component.modelCollection.add( [ widgetControl.model ] ); component.widgetControls[ widgetControl.model.get( 'widget_id' ) ] = widgetControl; widgetControl.render(); }; /** * Sync widget instance data sanitized from server back onto widget model. * * This gets called via the 'widget-updated' event when saving a widget from * the widgets admin screen and also via the 'widget-synced' event when making * a change to a widget in the customizer. * * @memberOf wp.mediaWidgets * * @param {jQuery.Event} event - Event. * @param {jQuery} widgetContainer - Widget container element. * * @return {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(). * * @memberOf wp.mediaWidgets * * @return {void} */ component.init = function init() { var $document = $( document ); $document.on( 'widget-added', component.handleWidgetAdded ); $document.on( 'widget-synced widget-updated', component.handleWidgetUpdated ); /* * Manually trigger widget-added events for media widgets on the admin * screen once they are expanded. The widget-added event is not triggered * for each pre-existing widget on the widgets admin screen like it is * on the customizer. Likewise, the customizer only triggers widget-added * when the widget is expanded to just-in-time construct the widget form * when it is actually going to be displayed. So the following implements * the same for the widgets admin screen, to invoke the widget-added * handler when a pre-existing media widget is expanded. */ $( function initializeExistingWidgetContainers() { var widgetContainers; if ( 'widgets' !== window.pagenow ) { return; } widgetContainers = $( '.widgets-holder-wrap:not(#available-widgets)' ).find( 'div.widget' ); widgetContainers.one( 'click.toggle-widget-expanded', function toggleWidgetExpanded() { var widgetContainer = $( this ); component.handleWidgetAdded( new jQuery.Event( 'widget-added' ), widgetContainer ); }); // Accessibility mode. if ( document.readyState === 'complete' ) { // Page is fully loaded. component.setupAccessibleMode(); } else { // Page is still loading. $( window ).on( 'load', function() { component.setupAccessibleMode(); }); } }); }; return component; })( jQuery );