/* global _wpMediaViewsL10n, confirm, getUserSetting, setUserSetting */ ( function( $, _ ) { var l10n, media = wp.media, isTouchDevice = ( 'ontouchend' in document ); // Link any localized strings. l10n = media.view.l10n = typeof _wpMediaViewsL10n === 'undefined' ? {} : _wpMediaViewsL10n; // Link any settings. media.view.settings = l10n.settings || {}; delete l10n.settings; // Copy the `post` setting over to the model settings. media.model.settings.post = media.view.settings.post; // Check if the browser supports CSS 3.0 transitions $.support.transition = (function(){ var style = document.documentElement.style, transitions = { WebkitTransition: 'webkitTransitionEnd', MozTransition: 'transitionend', OTransition: 'oTransitionEnd otransitionend', transition: 'transitionend' }, transition; transition = _.find( _.keys( transitions ), function( transition ) { return ! _.isUndefined( style[ transition ] ); }); return transition && { end: transitions[ transition ] }; }()); /** * A shared event bus used to provide events into * the media workflows that 3rd-party devs can use to hook * in. */ media.events = _.extend( {}, Backbone.Events ); /** * Makes it easier to bind events using transitions. * * @param {string} selector * @param {Number} sensitivity * @returns {Promise} */ media.transition = function( selector, sensitivity ) { var deferred = $.Deferred(); sensitivity = sensitivity || 2000; if ( $.support.transition ) { if ( ! (selector instanceof $) ) { selector = $( selector ); } // Resolve the deferred when the first element finishes animating. selector.first().one( $.support.transition.end, deferred.resolve ); // Just in case the event doesn't trigger, fire a callback. _.delay( deferred.resolve, sensitivity ); // Otherwise, execute on the spot. } else { deferred.resolve(); } return deferred.promise(); }; /** * ======================================================================== * CONTROLLERS * ======================================================================== */ /** * wp.media.controller.Region * * @constructor * @augments Backbone.Model * * @param {Object} [options={}] */ media.controller.Region = function( options ) { _.extend( this, _.pick( options || {}, 'id', 'view', 'selector' ) ); }; // Use Backbone's self-propagating `extend` inheritance method. media.controller.Region.extend = Backbone.Model.extend; _.extend( media.controller.Region.prototype, { /** * Activate a mode. * * @param {string} mode * * @fires this.view#{this.id}:activate:{this._mode} * @fires this.view#{this.id}:activate * @fires this.view#{this.id}:deactivate:{this._mode} * @fires this.view#{this.id}:deactivate * * @returns {wp.media.controller.Region} Returns itself to allow chaining. */ mode: function( mode ) { if ( ! mode ) { return this._mode; } // Bail if we're trying to change to the current mode. if ( mode === this._mode ) { return this; } /** * Region mode deactivation event. * * @event this.view#{this.id}:deactivate:{this._mode} * @event this.view#{this.id}:deactivate */ this.trigger('deactivate'); this._mode = mode; this.render( mode ); /** * Region mode activation event. * * @event this.view#{this.id}:activate:{this._mode} * @event this.view#{this.id}:activate */ this.trigger('activate'); return this; }, /** * Render a mode. * * @param {string} mode * * @fires this.view#{this.id}:create:{this._mode} * @fires this.view#{this.id}:create * @fires this.view#{this.id}:render:{this._mode} * @fires this.view#{this.id}:render * * @returns {wp.media.controller.Region} Returns itself to allow chaining */ render: function( mode ) { // If the mode isn't active, activate it. if ( mode && mode !== this._mode ) { return this.mode( mode ); } var set = { view: null }, view; /** * Create region view event. * * Region view creation takes place in an event callback on the frame. * * @event this.view#{this.id}:create:{this._mode} * @event this.view#{this.id}:create */ this.trigger( 'create', set ); view = set.view; /** * Render region view event. * * Region view creation takes place in an event callback on the frame. * * @event this.view#{this.id}:create:{this._mode} * @event this.view#{this.id}:create */ this.trigger( 'render', view ); if ( view ) { this.set( view ); } return this; }, /** * Get the region's view. * * @returns {wp.media.View} */ get: function() { return this.view.views.first( this.selector ); }, /** * Set the region's view as a subview of the frame. * * @param {Array|Object} views * @param {Object} [options={}] * @returns {wp.Backbone.Subviews} Subviews is returned to allow chaining */ set: function( views, options ) { if ( options ) { options.add = false; } return this.view.views.set( this.selector, views, options ); }, /** * Trigger regional view events on the frame. * * @param {string} event * @returns {undefined|wp.media.controller.Region} Returns itself to allow chaining. */ trigger: function( event ) { var base, args; if ( ! this._mode ) { return; } args = _.toArray( arguments ); base = this.id + ':' + event; // Trigger `{this.id}:{event}:{this._mode}` event on the frame. args[0] = base + ':' + this._mode; this.view.trigger.apply( this.view, args ); // Trigger `{this.id}:{event}` event on the frame. args[0] = base; this.view.trigger.apply( this.view, args ); return this; } }); /** * wp.media.controller.StateMachine * * @constructor * @augments Backbone.Model * @mixin * @mixes Backbone.Events * * @param {Array} states */ media.controller.StateMachine = function( states ) { this.states = new Backbone.Collection( states ); }; // Use Backbone's self-propagating `extend` inheritance method. media.controller.StateMachine.extend = Backbone.Model.extend; _.extend( media.controller.StateMachine.prototype, Backbone.Events, { /** * Fetch a state. * * If no `id` is provided, returns the active state. * * Implicitly creates states. * * Ensure that the `states` collection exists so the `StateMachine` * can be used as a mixin. * * @param {string} id * @returns {wp.media.controller.State} Returns a State model * from the StateMachine collection */ state: function( id ) { this.states = this.states || new Backbone.Collection(); // Default to the active state. id = id || this._state; if ( id && ! this.states.get( id ) ) { this.states.add({ id: id }); } return this.states.get( id ); }, /** * Sets the active state. * * Bail if we're trying to select the current state, if we haven't * created the `states` collection, or are trying to select a state * that does not exist. * * @param {string} id * * @fires wp.media.controller.State#deactivate * @fires wp.media.controller.State#activate * * @returns {wp.media.controller.StateMachine} Returns itself to allow chaining */ setState: function( id ) { var previous = this.state(); if ( ( previous && id === previous.id ) || ! this.states || ! this.states.get( id ) ) { return this; } if ( previous ) { previous.trigger('deactivate'); this._lastState = previous.id; } this._state = id; this.state().trigger('activate'); return this; }, /** * Returns the previous active state. * * Call the `state()` method with no parameters to retrieve the current * active state. * * @returns {wp.media.controller.State} Returns a State model * from the StateMachine collection */ lastState: function() { if ( this._lastState ) { return this.state( this._lastState ); } } }); // Map all event binding and triggering on a StateMachine to its `states` collection. _.each([ 'on', 'off', 'trigger' ], function( method ) { /** * @returns {wp.media.controller.StateMachine} Returns itself to allow chaining. */ media.controller.StateMachine.prototype[ method ] = function() { // Ensure that the `states` collection exists so the `StateMachine` // can be used as a mixin. this.states = this.states || new Backbone.Collection(); // Forward the method to the `states` collection. this.states[ method ].apply( this.states, arguments ); return this; }; }); /** * wp.media.controller.State * * A state is a step in a workflow that when set will trigger the controllers * for the regions to be updated as specified in the frame. This is the base * class that the various states used in wp.media extend. * * @constructor * @augments Backbone.Model */ media.controller.State = Backbone.Model.extend({ constructor: function() { this.on( 'activate', this._preActivate, this ); this.on( 'activate', this.activate, this ); this.on( 'activate', this._postActivate, this ); this.on( 'deactivate', this._deactivate, this ); this.on( 'deactivate', this.deactivate, this ); this.on( 'reset', this.reset, this ); this.on( 'ready', this._ready, this ); this.on( 'ready', this.ready, this ); /** * Call parent constructor with passed arguments */ Backbone.Model.apply( this, arguments ); this.on( 'change:menu', this._updateMenu, this ); }, /** * @abstract */ ready: function() {}, /** * @abstract */ activate: function() {}, /** * @abstract */ deactivate: function() {}, /** * @abstract */ reset: function() {}, /** * @access private */ _ready: function() { this._updateMenu(); }, /** * @access private */ _preActivate: function() { this.active = true; }, /** * @access private */ _postActivate: function() { this.on( 'change:menu', this._menu, this ); this.on( 'change:titleMode', this._title, this ); this.on( 'change:content', this._content, this ); this.on( 'change:toolbar', this._toolbar, this ); this.frame.on( 'title:render:default', this._renderTitle, this ); this._title(); this._menu(); this._toolbar(); this._content(); this._router(); }, /** * @access private */ _deactivate: function() { this.active = false; this.frame.off( 'title:render:default', this._renderTitle, this ); this.off( 'change:menu', this._menu, this ); this.off( 'change:titleMode', this._title, this ); this.off( 'change:content', this._content, this ); this.off( 'change:toolbar', this._toolbar, this ); }, /** * @access private */ _title: function() { this.frame.title.render( this.get('titleMode') || 'default' ); }, /** * @access private */ _renderTitle: function( view ) { view.$el.text( this.get('title') || '' ); }, /** * @access private */ _router: function() { var router = this.frame.router, mode = this.get('router'), view; this.frame.$el.toggleClass( 'hide-router', ! mode ); if ( ! mode ) { return; } this.frame.router.render( mode ); view = router.get(); if ( view && view.select ) { view.select( this.frame.content.mode() ); } }, /** * @access private */ _menu: function() { var menu = this.frame.menu, mode = this.get('menu'), view; if ( ! mode ) { return; } menu.mode( mode ); view = menu.get(); if ( view && view.select ) { view.select( this.id ); } }, /** * @access private */ _updateMenu: function() { var previous = this.previous('menu'), menu = this.get('menu'); if ( previous ) { this.frame.off( 'menu:render:' + previous, this._renderMenu, this ); } if ( menu ) { this.frame.on( 'menu:render:' + menu, this._renderMenu, this ); } }, /** * @access private */ _renderMenu: function( view ) { var menuItem = this.get('menuItem'), title = this.get('title'), priority = this.get('priority'); if ( ! menuItem && title ) { menuItem = { text: title }; if ( priority ) { menuItem.priority = priority; } } if ( ! menuItem ) { return; } view.set( this.id, menuItem ); } }); _.each(['toolbar','content'], function( region ) { /** * @access private */ media.controller.State.prototype[ '_' + region ] = function() { var mode = this.get( region ); if ( mode ) { this.frame[ region ].render( mode ); } }; }); media.selectionSync = { syncSelection: function() { var selection = this.get('selection'), manager = this.frame._selection; if ( ! this.get('syncSelection') || ! manager || ! selection ) { return; } // If the selection supports multiple items, validate the stored // attachments based on the new selection's conditions. Record // the attachments that are not included; we'll maintain a // reference to those. Other attachments are considered in flux. if ( selection.multiple ) { selection.reset( [], { silent: true }); selection.validateAll( manager.attachments ); manager.difference = _.difference( manager.attachments.models, selection.models ); } // Sync the selection's single item with the master. selection.single( manager.single ); }, /** * Record the currently active attachments, which is a combination * of the selection's attachments and the set of selected * attachments that this specific selection considered invalid. * Reset the difference and record the single attachment. */ recordSelection: function() { var selection = this.get('selection'), manager = this.frame._selection; if ( ! this.get('syncSelection') || ! manager || ! selection ) { return; } if ( selection.multiple ) { manager.attachments.reset( selection.toArray().concat( manager.difference ) ); manager.difference = []; } else { manager.attachments.add( selection.toArray() ); } manager.single = selection._single; } }; /** * A state for choosing an attachment from the media library. * * @constructor * @augments wp.media.controller.State * @augments Backbone.Model */ media.controller.Library = media.controller.State.extend({ defaults: { id: 'library', title: l10n.mediaLibraryTitle, // Selection defaults. @see media.model.Selection multiple: false, // Initial region modes. content: 'upload', menu: 'default', router: 'browse', toolbar: 'select', // Attachments browser defaults. @see media.view.AttachmentsBrowser searchable: true, filterable: false, sortable: true, autoSelect: true, describe: false, // Uses a user setting to override the content mode. contentUserSetting: true, // Sync the selection from the last state when 'multiple' matches. syncSelection: true }, /** * If a library isn't provided, query all media items. * If a selection instance isn't provided, create one. */ initialize: function() { var selection = this.get('selection'), props; if ( ! this.get('library') ) { this.set( 'library', media.query() ); } if ( ! (selection instanceof media.model.Selection) ) { props = selection; if ( ! props ) { props = this.get('library').props.toJSON(); props = _.omit( props, 'orderby', 'query' ); } // If the `selection` attribute is set to an object, // it will use those values as the selection instance's // `props` model. Otherwise, it will copy the library's // `props` model. this.set( 'selection', new media.model.Selection( null, { multiple: this.get('multiple'), props: props }) ); } this.resetDisplays(); }, activate: function() { this.syncSelection(); wp.Uploader.queue.on( 'add', this.uploading, this ); this.get('selection').on( 'add remove reset', this.refreshContent, this ); if ( this.get( 'router' ) && this.get('contentUserSetting') ) { this.frame.on( 'content:activate', this.saveContentMode, this ); this.set( 'content', getUserSetting( 'libraryContent', this.get('content') ) ); } }, deactivate: function() { this.recordSelection(); this.frame.off( 'content:activate', this.saveContentMode, this ); // Unbind all event handlers that use this state as the context // from the selection. this.get('selection').off( null, null, this ); wp.Uploader.queue.off( null, null, this ); }, reset: function() { this.get('selection').reset(); this.resetDisplays(); this.refreshContent(); }, resetDisplays: function() { var defaultProps = media.view.settings.defaultProps; this._displays = []; this._defaultDisplaySettings = { align: defaultProps.align || getUserSetting( 'align', 'none' ), size: defaultProps.size || getUserSetting( 'imgsize', 'medium' ), link: defaultProps.link || getUserSetting( 'urlbutton', 'file' ) }; }, /** * @param {wp.media.model.Attachment} attachment * @returns {Backbone.Model} */ display: function( attachment ) { var displays = this._displays; if ( ! displays[ attachment.cid ] ) { displays[ attachment.cid ] = new Backbone.Model( this.defaultDisplaySettings( attachment ) ); } return displays[ attachment.cid ]; }, /** * @param {wp.media.model.Attachment} attachment * @returns {Object} */ defaultDisplaySettings: function( attachment ) { var settings = this._defaultDisplaySettings; if ( settings.canEmbed = this.canEmbed( attachment ) ) { settings.link = 'embed'; } return settings; }, /** * @param {wp.media.model.Attachment} attachment * @returns {Boolean} */ canEmbed: function( attachment ) { // If uploading, we know the filename but not the mime type. if ( ! attachment.get('uploading') ) { var type = attachment.get('type'); if ( type !== 'audio' && type !== 'video' ) { return false; } } return _.contains( media.view.settings.embedExts, attachment.get('filename').split('.').pop() ); }, /** * If the state is active, no items are selected, and the current * content mode is not an option in the state's router (provided * the state has a router), reset the content mode to the default. */ refreshContent: function() { var selection = this.get('selection'), frame = this.frame, router = frame.router.get(), mode = frame.content.mode(); if ( this.active && ! selection.length && router && ! router.get( mode ) ) { this.frame.content.render( this.get('content') ); } }, /** * If the uploader was selected, navigate to the browser. * * Automatically select any uploading attachments. * * Selections that don't support multiple attachments automatically * limit themselves to one attachment (in this case, the last * attachment in the upload queue). * * @param {wp.media.model.Attachment} attachment */ uploading: function( attachment ) { var content = this.frame.content; if ( 'upload' === content.mode() ) { this.frame.content.mode('browse'); } if ( this.get( 'autoSelect' ) ) { this.get('selection').add( attachment ); this.frame.trigger( 'library:selection:add' ); } }, /** * Only track the browse router on library states. */ saveContentMode: function() { if ( 'browse' !== this.get('router') ) { return; } var mode = this.frame.content.mode(), view = this.frame.router.get(); if ( view && view.get( mode ) ) { setUserSetting( 'libraryContent', mode ); } } }); _.extend( media.controller.Library.prototype, media.selectionSync ); /** * A state for editing the settings of an image within a content editor. * * @constructor * @augments wp.media.controller.State * @augments Backbone.Model */ media.controller.ImageDetails = media.controller.State.extend({ defaults: _.defaults({ id: 'image-details', title: l10n.imageDetailsTitle, // Initial region modes. content: 'image-details', menu: 'image-details', router: false, toolbar: 'image-details', editing: false, priority: 60 }, media.controller.Library.prototype.defaults ), initialize: function( options ) { this.image = options.image; media.controller.State.prototype.initialize.apply( this, arguments ); }, activate: function() { this.frame.modal.$el.addClass('image-details'); } }); /** * A state for editing a gallery's images and settings. * * @constructor * @augments wp.media.controller.Library * @augments wp.media.controller.State * @augments Backbone.Model */ media.controller.GalleryEdit = media.controller.Library.extend({ defaults: { id: 'gallery-edit', title: l10n.editGalleryTitle, // Selection defaults. @see media.model.Selection multiple: false, // Attachments browser defaults. @see media.view.AttachmentsBrowser searchable: false, sortable: true, display: false, // Initial region modes. content: 'browse', toolbar: 'gallery-edit', describe: true, displaySettings: true, dragInfo: true, idealColumnWidth: 170, editing: false, priority: 60, // Don't sync the selection, as the Edit Gallery library // *is* the selection. syncSelection: false }, initialize: function() { // If we haven't been provided a `library`, create a `Selection`. if ( ! this.get('library') ) this.set( 'library', new media.model.Selection() ); // The single `Attachment` view to be used in the `Attachments` view. if ( ! this.get('AttachmentView') ) this.set( 'AttachmentView', media.view.Attachment.EditLibrary ); media.controller.Library.prototype.initialize.apply( this, arguments ); }, activate: function() { var library = this.get('library'); // Limit the library to images only. library.props.set( 'type', 'image' ); // Watch for uploaded attachments. this.get('library').observe( wp.Uploader.queue ); this.frame.on( 'content:render:browse', this.gallerySettings, this ); media.controller.Library.prototype.activate.apply( this, arguments ); }, deactivate: function() { // Stop watching for uploaded attachments. this.get('library').unobserve( wp.Uploader.queue ); this.frame.off( 'content:render:browse', this.gallerySettings, this ); media.controller.Library.prototype.deactivate.apply( this, arguments ); }, gallerySettings: function( browser ) { if ( ! this.get('displaySettings') ) { return; } var library = this.get('library'); if ( ! library || ! browser ) { return; } library.gallery = library.gallery || new Backbone.Model(); browser.sidebar.set({ gallery: new media.view.Settings.Gallery({ controller: this, model: library.gallery, priority: 40 }) }); browser.toolbar.set( 'reverse', { text: l10n.reverseOrder, priority: 80, click: function() { library.reset( library.toArray().reverse() ); } }); } }); /** * A state for adding an image to a gallery. * * @constructor * @augments wp.media.controller.Library * @augments wp.media.controller.State * @augments Backbone.Model */ media.controller.GalleryAdd = media.controller.Library.extend({ defaults: _.defaults({ id: 'gallery-library', title: l10n.addToGalleryTitle, // Selection defaults. @see media.model.Selection multiple: 'add', // Attachments browser defaults. @see media.view.AttachmentsBrowser filterable: 'uploaded', // Initial region modes. menu: 'gallery', toolbar: 'gallery-add', priority: 100, // Don't sync the selection, as the Edit Gallery library // *is* the selection. syncSelection: false }, media.controller.Library.prototype.defaults ), initialize: function() { // If we haven't been provided a `library`, create a `Selection`. if ( ! this.get('library') ) this.set( 'library', media.query({ type: 'image' }) ); media.controller.Library.prototype.initialize.apply( this, arguments ); }, activate: function() { var library = this.get('library'), edit = this.frame.state('gallery-edit').get('library'); if ( this.editLibrary && this.editLibrary !== edit ) library.unobserve( this.editLibrary ); // Accepts attachments that exist in the original library and // that do not exist in gallery's library. library.validator = function( attachment ) { return !! this.mirroring.get( attachment.cid ) && ! edit.get( attachment.cid ) && media.model.Selection.prototype.validator.apply( this, arguments ); }; // Reset the library to ensure that all attachments are re-added // to the collection. Do so silently, as calling `observe` will // trigger the `reset` event. library.reset( library.mirroring.models, { silent: true }); library.observe( edit ); this.editLibrary = edit; media.controller.Library.prototype.activate.apply( this, arguments ); } }); /** * wp.media.controller.CollectionEdit * * @constructor * @augments wp.media.controller.Library * @augments wp.media.controller.State * @augments Backbone.Model */ media.controller.CollectionEdit = media.controller.Library.extend({ defaults: { // Selection defaults. @see media.model.Selection multiple: false, // Attachments browser defaults. @see media.view.AttachmentsBrowser sortable: true, searchable: false, // Region mode defaults. content: 'browse', describe: true, dragInfo: true, idealColumnWidth: 170, editing: false, priority: 60, SettingsView: false, // Don't sync the selection, as the Edit {Collection} library // *is* the selection. syncSelection: false }, initialize: function() { var collectionType = this.get('collectionType'); if ( 'video' === this.get( 'type' ) ) { collectionType = 'video-' + collectionType; } this.set( 'id', collectionType + '-edit' ); this.set( 'toolbar', collectionType + '-edit' ); // If we haven't been provided a `library`, create a `Selection`. if ( ! this.get('library') ) { this.set( 'library', new media.model.Selection() ); } // The single `Attachment` view to be used in the `Attachments` view. if ( ! this.get('AttachmentView') ) { this.set( 'AttachmentView', media.view.Attachment.EditLibrary ); } media.controller.Library.prototype.initialize.apply( this, arguments ); }, activate: function() { var library = this.get('library'); // Limit the library to images only. library.props.set( 'type', this.get( 'type' ) ); // Watch for uploaded attachments. this.get('library').observe( wp.Uploader.queue ); this.frame.on( 'content:render:browse', this.renderSettings, this ); media.controller.Library.prototype.activate.apply( this, arguments ); }, deactivate: function() { // Stop watching for uploaded attachments. this.get('library').unobserve( wp.Uploader.queue ); this.frame.off( 'content:render:browse', this.renderSettings, this ); media.controller.Library.prototype.deactivate.apply( this, arguments ); }, renderSettings: function( browser ) { var library = this.get('library'), collectionType = this.get('collectionType'), dragInfoText = this.get('dragInfoText'), SettingsView = this.get('SettingsView'), obj = {}; if ( ! library || ! browser ) { return; } library[ collectionType ] = library[ collectionType ] || new Backbone.Model(); obj[ collectionType ] = new SettingsView({ controller: this, model: library[ collectionType ], priority: 40 }); browser.sidebar.set( obj ); if ( dragInfoText ) { browser.toolbar.set( 'dragInfo', new media.View({ el: $( '