diff -r f507feede89a -r 09a1c134465b web/wp-includes/js/media-views.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/wp-includes/js/media-views.js Wed Dec 19 17:46:52 2012 -0800 @@ -0,0 +1,4289 @@ +(function($){ + var media = wp.media, + Attachment = media.model.Attachment, + Attachments = media.model.Attachments, + Query = media.model.Query, + l10n; + + // 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 ] + }; + }()); + + // Makes it easier to bind events using transitions. + 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 + */ + 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, { + 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; + + this.trigger('deactivate'); + this._mode = mode; + this.render( mode ); + this.trigger('activate'); + return this; + }, + + render: function( mode ) { + // If no mode is provided, just re-render the current mode. + // If the provided mode isn't active, perform a full switch. + if ( mode && mode !== this._mode ) + return this.mode( mode ); + + var set = { view: null }, + view; + + this.trigger( 'create', set ); + view = set.view; + this.trigger( 'render', view ); + if ( view ) + this.set( view ); + return this; + }, + + get: function() { + return this.view.views.first( this.selector ); + }, + + set: function( views, options ) { + if ( options ) + options.add = false; + return this.view.views.set( this.selector, views, options ); + }, + + trigger: function( event ) { + var base; + if ( ! this._mode ) + return; + + var args = _.toArray( arguments ); + base = this.id + ':' + event; + + // Trigger `region:action:mode` event. + args[0] = base + ':' + this._mode; + this.view.trigger.apply( this.view, args ); + + // Trigger `region:action` event. + args[0] = base; + this.view.trigger.apply( this.view, args ); + return this; + } + }); + + /** + * wp.media.controller.StateMachine + */ + 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; + + // Add events to the `StateMachine`. + _.extend( media.controller.StateMachine.prototype, Backbone.Events, { + + // Fetch a state. + // + // If no `id` is provided, returns the active state. + // + // Implicitly creates states. + state: function( id ) { + // Ensure that the `states` collection exists so the `StateMachine` + // can be used as a mixin. + 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. + setState: function( id ) { + var previous = this.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. + 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. + lastState: function() { + if ( this._lastState ) + return this.state( this._lastState ); + } + }); + + // Map methods from the `states` collection to the `StateMachine` itself. + _.each([ 'on', 'off', 'trigger' ], function( method ) { + 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 + // --------------------------- + 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 ); + + this.on( 'change:menu', this._updateMenu, this ); + + Backbone.Model.apply( this, arguments ); + }, + + ready: function() {}, + activate: function() {}, + deactivate: function() {}, + reset: function() {}, + + _ready: function() { + this._updateMenu(); + }, + + _preActivate: function() { + this.active = true; + }, + + _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(); + }, + + + _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 ); + }, + + _title: function() { + this.frame.title.render( this.get('titleMode') || 'default' ); + }, + + _renderTitle: function( view ) { + view.$el.text( this.get('title') || '' ); + }, + + _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() ); + }, + + _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 ); + }, + + _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 ); + }, + + _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 ) { + media.controller.State.prototype[ '_' + region ] = function() { + var mode = this.get( region ); + if ( mode ) + this.frame[ region ].render( mode ); + }; + }); + + // wp.media.controller.Library + // --------------------------- + media.controller.Library = media.controller.State.extend({ + defaults: { + id: 'library', + multiple: false, // false, 'add', 'reset' + describe: false, + toolbar: 'select', + sidebar: 'settings', + content: 'upload', + router: 'browse', + menu: 'default', + searchable: true, + filterable: false, + sortable: true, + title: l10n.mediaLibraryTitle, + + // Uses a user setting to override the content mode. + contentUserSetting: true, + + // Sync the selection from the last state when 'multiple' matches. + syncSelection: true + }, + + initialize: function() { + var selection = this.get('selection'), + props; + + // If a library isn't provided, query all media items. + if ( ! this.get('library') ) + this.set( 'library', media.query() ); + + // If a selection instance isn't provided, create one. + 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 + }) ); + } + + if ( ! this.get('edge') ) + this.set( 'edge', 120 ); + + if ( ! this.get('gutter') ) + this.set( 'gutter', 8 ); + + this.resetDisplays(); + }, + + activate: function() { + this.syncSelection(); + + wp.Uploader.queue.on( 'add', this.uploading, this ); + + this.get('selection').on( 'add remove reset', this.refreshContent, this ); + + this.on( 'insert', this._insertDisplaySettings, this ); + + if ( 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() { + this._displays = []; + this._defaultDisplaySettings = { + align: getUserSetting( 'align', 'none' ), + size: getUserSetting( 'imgsize', 'medium' ), + link: getUserSetting( 'urlbutton', 'post' ) + }; + }, + + display: function( attachment ) { + var displays = this._displays; + + if ( ! displays[ attachment.cid ] ) + displays[ attachment.cid ] = new Backbone.Model( this._defaultDisplaySettings ); + + return displays[ attachment.cid ]; + }, + + _insertDisplaySettings: function() { + var selection = this.get('selection'), + display; + + // If inserting one image, set those display properties as the + // default user setting. + if ( selection.length !== 1 ) + return; + + display = this.display( selection.first() ).toJSON(); + + setUserSetting( 'align', display.align ); + setUserSetting( 'imgsize', display.size ); + setUserSetting( 'urlbutton', display.link ); + }, + + 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 ); + }, + + recordSelection: function() { + var selection = this.get('selection'), + manager = this.frame._selection, + filtered; + + if ( ! this.get('syncSelection') || ! manager || ! selection ) + return; + + // 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. + if ( selection.multiple ) { + manager.attachments.reset( selection.toArray().concat( manager.difference ) ); + manager.difference = []; + } else { + manager.attachments.add( selection.toArray() ); + } + + manager.single = selection._single; + }, + + refreshContent: function() { + var selection = this.get('selection'), + frame = this.frame, + router = frame.router.get(), + mode = frame.content.mode(); + + if ( this.active && ! selection.length && ! router.get( mode ) ) + this.frame.content.render( this.get('content') ); + }, + + uploading: function( attachment ) { + var content = this.frame.content; + + // If the uploader was selected, navigate to the browser. + if ( 'upload' === content.mode() ) + this.frame.content.mode('browse'); + + // If we're in a workflow that supports multiple attachments, + // automatically select any uploading attachments. + if ( this.get('multiple') ) + this.get('selection').add( attachment ); + }, + + saveContentMode: function() { + // Only track the browse router on library states. + 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 ); + } + }); + + // wp.media.controller.GalleryEdit + // ------------------------------- + media.controller.GalleryEdit = media.controller.Library.extend({ + defaults: { + id: 'gallery-edit', + multiple: false, + describe: true, + edge: 199, + editing: false, + sortable: true, + searchable: false, + toolbar: 'gallery-edit', + content: 'browse', + title: l10n.editGalleryTitle, + priority: 60, + dragInfo: true, + + // 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 ) { + 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() ); + } + }); + } + }); + + // wp.media.controller.GalleryAdd + // --------------------------------- + media.controller.GalleryAdd = media.controller.Library.extend({ + defaults: _.defaults({ + id: 'gallery-library', + filterable: 'uploaded', + multiple: 'add', + menu: 'gallery', + toolbar: 'gallery-add', + title: l10n.addToGalleryTitle, + 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.getByCid( attachment.cid ) && ! edit.getByCid( attachment.cid ) && media.model.Selection.prototype.validator.apply( this, arguments ); + }; + + library.observe( edit ); + this.editLibrary = edit; + + media.controller.Library.prototype.activate.apply( this, arguments ); + } + }); + + // wp.media.controller.FeaturedImage + // --------------------------------- + media.controller.FeaturedImage = media.controller.Library.extend({ + defaults: _.defaults({ + id: 'featured-image', + filterable: 'uploaded', + multiple: false, + toolbar: 'featured-image', + title: l10n.setFeaturedImageTitle, + priority: 60, + + syncSelection: false + }, media.controller.Library.prototype.defaults ), + + initialize: function() { + var library, comparator; + + // 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 ); + + library = this.get('library'); + comparator = library.comparator; + + // Overload the library's comparator to push items that are not in + // the mirrored query to the front of the aggregate collection. + library.comparator = function( a, b ) { + var aInQuery = !! this.mirroring.getByCid( a.cid ), + bInQuery = !! this.mirroring.getByCid( b.cid ); + + if ( ! aInQuery && bInQuery ) + return -1; + else if ( aInQuery && ! bInQuery ) + return 1; + else + return comparator.apply( this, arguments ); + }; + + // Add all items in the selection to the library, so any featured + // images that are not initially loaded still appear. + library.observe( this.get('selection') ); + }, + + activate: function() { + this.updateSelection(); + this.frame.on( 'open', this.updateSelection, this ); + media.controller.Library.prototype.activate.apply( this, arguments ); + }, + + deactivate: function() { + this.frame.off( 'open', this.updateSelection, this ); + media.controller.Library.prototype.deactivate.apply( this, arguments ); + }, + + updateSelection: function() { + var selection = this.get('selection'), + id = media.view.settings.post.featuredImageId, + attachment; + + if ( '' !== id && -1 !== id ) { + attachment = Attachment.get( id ); + attachment.fetch(); + } + + selection.reset( attachment ? [ attachment ] : [] ); + } + }); + + + // wp.media.controller.Embed + // ------------------------- + media.controller.Embed = media.controller.State.extend({ + defaults: { + id: 'embed', + url: '', + menu: 'default', + content: 'embed', + toolbar: 'main-embed', + type: 'link', + + title: l10n.insertFromUrlTitle, + priority: 120 + }, + + // The amount of time used when debouncing the scan. + sensitivity: 200, + + initialize: function() { + this.debouncedScan = _.debounce( _.bind( this.scan, this ), this.sensitivity ); + this.props = new Backbone.Model({ url: '' }); + this.props.on( 'change:url', this.debouncedScan, this ); + this.props.on( 'change:url', this.refresh, this ); + this.on( 'scan', this.scanImage, this ); + }, + + scan: function() { + var scanners, + embed = this, + attributes = { + type: 'link', + scanners: [] + }; + + // Scan is triggered with the list of `attributes` to set on the + // state, useful for the 'type' attribute and 'scanners' attribute, + // an array of promise objects for asynchronous scan operations. + if ( this.props.get('url') ) + this.trigger( 'scan', attributes ); + + if ( attributes.scanners.length ) { + scanners = attributes.scanners = $.when.apply( $, attributes.scanners ); + scanners.always( function() { + if ( embed.get('scanners') === scanners ) + embed.set( 'loading', false ); + }); + } else { + attributes.scanners = null; + } + + attributes.loading = !! attributes.scanners; + this.set( attributes ); + }, + + scanImage: function( attributes ) { + var frame = this.frame, + state = this, + url = this.props.get('url'), + image = new Image(), + deferred = $.Deferred(); + + attributes.scanners.push( deferred.promise() ); + + // Try to load the image and find its width/height. + image.onload = function() { + deferred.resolve(); + + if ( state !== frame.state() || url !== state.props.get('url') ) + return; + + state.set({ + type: 'image' + }); + + state.props.set({ + width: image.width, + height: image.height + }); + }; + + image.onerror = deferred.reject; + image.src = url; + }, + + refresh: function() { + this.frame.toolbar.get().refresh(); + }, + + reset: function() { + this.props.clear().set({ url: '' }); + + if ( this.active ) + this.refresh(); + } + }); + + /** + * ======================================================================== + * VIEWS + * ======================================================================== + */ + + // wp.media.Views + // ------------- + // + // A subview manager. + + media.Views = function( view, views ) { + this.view = view; + this._views = _.isArray( views ) ? { '': views } : views || {}; + }; + + media.Views.extend = Backbone.Model.extend; + + _.extend( media.Views.prototype, { + // ### Fetch all of the subviews + // + // Returns an array of all subviews. + all: function() { + return _.flatten( this._views ); + }, + + // ### Get a selector's subviews + // + // Fetches all subviews that match a given `selector`. + // + // If no `selector` is provided, it will grab all subviews attached + // to the view's root. + get: function( selector ) { + selector = selector || ''; + return this._views[ selector ]; + }, + + // ### Get a selector's first subview + // + // Fetches the first subview that matches a given `selector`. + // + // If no `selector` is provided, it will grab the first subview + // attached to the view's root. + // + // Useful when a selector only has one subview at a time. + first: function( selector ) { + var views = this.get( selector ); + return views && views.length ? views[0] : null; + }, + + // ### Register subview(s) + // + // Registers any number of `views` to a `selector`. + // + // When no `selector` is provided, the root selector (the empty string) + // is used. `views` accepts a `Backbone.View` instance or an array of + // `Backbone.View` instances. + // + // --- + // + // Accepts an `options` object, which has a significant effect on the + // resulting behavior. + // + // `options.silent` – *boolean, `false`* + // > If `options.silent` is true, no DOM modifications will be made. + // + // `options.add` – *boolean, `false`* + // > Use `Views.add()` as a shortcut for setting `options.add` to true. + // + // > By default, the provided `views` will replace + // any existing views associated with the selector. If `options.add` + // is true, the provided `views` will be added to the existing views. + // + // `options.at` – *integer, `undefined`* + // > When adding, to insert `views` at a specific index, use + // `options.at`. By default, `views` are added to the end of the array. + set: function( selector, views, options ) { + var existing, next; + + if ( ! _.isString( selector ) ) { + options = views; + views = selector; + selector = ''; + } + + options = options || {}; + views = _.isArray( views ) ? views : [ views ]; + existing = this.get( selector ); + next = views; + + if ( existing ) { + if ( options.add ) { + if ( _.isUndefined( options.at ) ) { + next = existing.concat( views ); + } else { + next = existing; + next.splice.apply( next, [ options.at, 0 ].concat( views ) ); + } + } else { + _.each( next, function( view ) { + view.__detach = true; + }); + + _.each( existing, function( view ) { + if ( view.__detach ) + view.$el.detach(); + else + view.dispose(); + }); + + _.each( next, function( view ) { + delete view.__detach; + }); + } + } + + this._views[ selector ] = next; + + _.each( views, function( subview ) { + var constructor = subview.Views || media.Views, + subviews = subview.views = subview.views || new constructor( subview ); + subviews.parent = this.view; + subviews.selector = selector; + }, this ); + + if ( ! options.silent ) + this._attach( selector, views, _.extend({ ready: this._isReady() }, options ) ); + + return this; + }, + + // ### Add subview(s) to existing subviews + // + // An alias to `Views.set()`, which defaults `options.add` to true. + // + // Adds any number of `views` to a `selector`. + // + // When no `selector` is provided, the root selector (the empty string) + // is used. `views` accepts a `Backbone.View` instance or an array of + // `Backbone.View` instances. + // + // Use `Views.set()` when setting `options.add` to `false`. + // + // Accepts an `options` object. By default, provided `views` will be + // inserted at the end of the array of existing views. To insert + // `views` at a specific index, use `options.at`. If `options.silent` + // is true, no DOM modifications will be made. + // + // For more information on the `options` object, see `Views.set()`. + add: function( selector, views, options ) { + if ( ! _.isString( selector ) ) { + options = views; + views = selector; + selector = ''; + } + + return this.set( selector, views, _.extend({ add: true }, options ) ); + }, + + // ### Stop tracking subviews + // + // Stops tracking `views` registered to a `selector`. If no `views` are + // set, then all of the `selector`'s subviews will be unregistered and + // disposed. + // + // Accepts an `options` object. If `options.silent` is set, `dispose` + // will *not* be triggered on the unregistered views. + unset: function( selector, views, options ) { + var existing; + + if ( ! _.isString( selector ) ) { + options = views; + views = selector; + selector = ''; + } + + views = views || []; + + if ( existing = this.get( selector ) ) { + views = _.isArray( views ) ? views : [ views ]; + this._views[ selector ] = views.length ? _.difference( existing, views ) : []; + } + + if ( ! options || ! options.silent ) + _.invoke( views, 'dispose' ); + + return this; + }, + + // ### Detach all subviews + // + // Detaches all subviews from the DOM. + // + // Helps to preserve all subview events when re-rendering the master + // view. Used in conjunction with `Views.render()`. + detach: function() { + $( _.pluck( this.all(), 'el' ) ).detach(); + return this; + }, + + // ### Render all subviews + // + // Renders all subviews. Used in conjunction with `Views.detach()`. + render: function() { + var options = { + ready: this._isReady() + }; + + _.each( this._views, function( views, selector ) { + this._attach( selector, views, options ); + }, this ); + + this.rendered = true; + return this; + }, + + // ### Dispose all subviews + // + // Triggers the `dispose()` method on all subviews. Detaches the master + // view from its parent. Resets the internals of the views manager. + // + // Accepts an `options` object. If `options.silent` is set, `unset` + // will *not* be triggered on the master view's parent. + dispose: function( options ) { + if ( ! options || ! options.silent ) { + if ( this.parent && this.parent.views ) + this.parent.views.unset( this.selector, this.view, { silent: true }); + delete this.parent; + delete this.selector; + } + + _.invoke( this.all(), 'dispose' ); + this._views = []; + return this; + }, + + // ### Replace a selector's subviews + // + // By default, sets the `$target` selector's html to the subview `els`. + // + // Can be overridden in subclasses. + replace: function( $target, els ) { + $target.html( els ); + return this; + }, + + // ### Insert subviews into a selector + // + // By default, appends the subview `els` to the end of the `$target` + // selector. If `options.at` is set, inserts the subview `els` at the + // provided index. + // + // Can be overridden in subclasses. + insert: function( $target, els, options ) { + var at = options && options.at, + $children; + + if ( _.isNumber( at ) && ($children = $target.children()).length > at ) + $children.eq( at ).before( els ); + else + $target.append( els ); + + return this; + }, + + // ### Trigger the ready event + // + // **Only use this method if you know what you're doing.** + // For performance reasons, this method does not check if the view is + // actually attached to the DOM. It's taking your word for it. + // + // Fires the ready event on the current view and all attached subviews. + ready: function() { + this.view.trigger('ready'); + + // Find all attached subviews, and call ready on them. + _.chain( this.all() ).map( function( view ) { + return view.views; + }).flatten().where({ attached: true }).invoke('ready'); + }, + + // #### Internal. Attaches a series of views to a selector. + // + // Checks to see if a matching selector exists, renders the views, + // performs the proper DOM operation, and then checks if the view is + // attached to the document. + _attach: function( selector, views, options ) { + var $selector = selector ? this.view.$( selector ) : this.view.$el, + managers; + + // Check if we found a location to attach the views. + if ( ! $selector.length ) + return this; + + managers = _.chain( views ).pluck('views').flatten().value(); + + // Render the views if necessary. + _.each( managers, function( manager ) { + if ( manager.rendered ) + return; + + manager.view.render(); + manager.rendered = true; + }, this ); + + // Insert or replace the views. + this[ options.add ? 'insert' : 'replace' ]( $selector, _.pluck( views, 'el' ), options ); + + // Set attached and trigger ready if the current view is already + // attached to the DOM. + _.each( managers, function( manager ) { + manager.attached = true; + + if ( options.ready ) + manager.ready(); + }, this ); + + return this; + }, + + // #### Internal. Checks if the current view is in the DOM. + _isReady: function() { + var node = this.view.el; + while ( node ) { + if ( node === document.body ) + return true; + node = node.parentNode; + } + + return false; + } + }); + + // wp.media.View + // ------------- + // + // The base view class. + media.View = Backbone.View.extend({ + // The constructor for the `Views` manager. + Views: media.Views, + + constructor: function( options ) { + this.views = new this.Views( this, this.views ); + this.on( 'ready', this.ready, this ); + + if ( options && options.controller ) + this.controller = options.controller; + + Backbone.View.apply( this, arguments ); + }, + + dispose: function() { + // Undelegating events, removing events from the model, and + // removing events from the controller mirror the code for + // `Backbone.View.dispose` in Backbone master. + this.undelegateEvents(); + + if ( this.model && this.model.off ) + this.model.off( null, null, this ); + + if ( this.collection && this.collection.off ) + this.collection.off( null, null, this ); + + // Unbind controller events. + if ( this.controller && this.controller.off ) + this.controller.off( null, null, this ); + + // Recursively dispose child views. + if ( this.views ) + this.views.dispose(); + + return this; + }, + + remove: function() { + this.dispose(); + return Backbone.View.prototype.remove.apply( this, arguments ); + }, + + render: function() { + var options; + + if ( this.prepare ) + options = this.prepare(); + + this.views.detach(); + + if ( this.template ) { + options = options || {}; + this.trigger( 'prepare', options ); + this.$el.html( this.template( options ) ); + } + + this.views.render(); + return this; + }, + + prepare: function() { + return this.options; + }, + + ready: function() {} + }); + + /** + * wp.media.view.Frame + */ + media.view.Frame = media.View.extend({ + initialize: function() { + this._createRegions(); + this._createStates(); + }, + + _createRegions: function() { + // Clone the regions array. + this.regions = this.regions ? this.regions.slice() : []; + + // Initialize regions. + _.each( this.regions, function( region ) { + this[ region ] = new media.controller.Region({ + view: this, + id: region, + selector: '.media-frame-' + region + }); + }, this ); + }, + + _createStates: function() { + // Create the default `states` collection. + this.states = new Backbone.Collection( null, { + model: media.controller.State + }); + + // Ensure states have a reference to the frame. + this.states.on( 'add', function( model ) { + model.frame = this; + model.trigger('ready'); + }, this ); + + if ( this.options.states ) + this.states.add( this.options.states ); + }, + + reset: function() { + this.states.invoke( 'trigger', 'reset' ); + return this; + } + }); + + // Make the `Frame` a `StateMachine`. + _.extend( media.view.Frame.prototype, media.controller.StateMachine.prototype ); + + /** + * wp.media.view.MediaFrame + */ + media.view.MediaFrame = media.view.Frame.extend({ + className: 'media-frame', + template: media.template('media-frame'), + regions: ['menu','title','content','toolbar','router'], + + initialize: function() { + media.view.Frame.prototype.initialize.apply( this, arguments ); + + _.defaults( this.options, { + title: '', + modal: true, + uploader: true + }); + + // Ensure core UI is enabled. + this.$el.addClass('wp-core-ui'); + + // Initialize modal container view. + if ( this.options.modal ) { + this.modal = new media.view.Modal({ + controller: this, + title: this.options.title + }); + + this.modal.content( this ); + } + + // Force the uploader off if the upload limit has been exceeded or + // if the browser isn't supported. + if ( wp.Uploader.limitExceeded || ! wp.Uploader.browser.supported ) + this.options.uploader = false; + + // Initialize window-wide uploader. + if ( this.options.uploader ) { + this.uploader = new media.view.UploaderWindow({ + controller: this, + uploader: { + dropzone: this.modal ? this.modal.$el : this.$el, + container: this.$el + } + }); + this.views.set( '.media-frame-uploader', this.uploader ); + } + + this.on( 'attach', _.bind( this.views.ready, this.views ), this ); + + // Bind default title creation. + this.on( 'title:create:default', this.createTitle, this ); + this.title.mode('default'); + + // Bind default menu. + this.on( 'menu:create:default', this.createMenu, this ); + }, + + render: function() { + // Activate the default state if no active state exists. + if ( ! this.state() && this.options.state ) + this.setState( this.options.state ); + + return media.view.Frame.prototype.render.apply( this, arguments ); + }, + + createTitle: function( title ) { + title.view = new media.View({ + controller: this, + tagName: 'h1' + }); + }, + + createMenu: function( menu ) { + menu.view = new media.view.Menu({ + controller: this + }); + }, + + createToolbar: function( toolbar ) { + toolbar.view = new media.view.Toolbar({ + controller: this + }); + }, + + createRouter: function( router ) { + router.view = new media.view.Router({ + controller: this + }); + }, + + createIframeStates: function( options ) { + var settings = media.view.settings, + tabs = settings.tabs, + tabUrl = settings.tabUrl, + $postId; + + if ( ! tabs || ! tabUrl ) + return; + + // Add the post ID to the tab URL if it exists. + $postId = $('#post_ID'); + if ( $postId.length ) + tabUrl += '&post_id=' + $postId.val(); + + // Generate the tab states. + _.each( tabs, function( title, id ) { + var frame = this.state( 'iframe:' + id ).set( _.defaults({ + tab: id, + src: tabUrl + '&tab=' + id, + title: title, + content: 'iframe', + menu: 'default' + }, options ) ); + }, this ); + + this.on( 'content:create:iframe', this.iframeContent, this ); + this.on( 'menu:render:default', this.iframeMenu, this ); + this.on( 'open', this.hijackThickbox, this ); + this.on( 'close', this.restoreThickbox, this ); + }, + + iframeContent: function( content ) { + this.$el.addClass('hide-toolbar'); + content.view = new media.view.Iframe({ + controller: this + }); + }, + + iframeMenu: function( view ) { + var views = {}; + + if ( ! view ) + return; + + _.each( media.view.settings.tabs, function( title, id ) { + views[ 'iframe:' + id ] = { + text: this.state( 'iframe:' + id ).get('title'), + priority: 200 + }; + }, this ); + + view.set( views ); + }, + + hijackThickbox: function() { + var frame = this; + + if ( ! window.tb_remove || this._tb_remove ) + return; + + this._tb_remove = window.tb_remove; + window.tb_remove = function() { + frame.close(); + frame.reset(); + frame.setState( frame.options.state ); + frame._tb_remove.call( window ); + }; + }, + + restoreThickbox: function() { + if ( ! this._tb_remove ) + return; + + window.tb_remove = this._tb_remove; + delete this._tb_remove; + } + }); + + // Map some of the modal's methods to the frame. + _.each(['open','close','attach','detach','escape'], function( method ) { + media.view.MediaFrame.prototype[ method ] = function( view ) { + if ( this.modal ) + this.modal[ method ].apply( this.modal, arguments ); + return this; + }; + }); + + /** + * wp.media.view.MediaFrame.Select + */ + media.view.MediaFrame.Select = media.view.MediaFrame.extend({ + initialize: function() { + media.view.MediaFrame.prototype.initialize.apply( this, arguments ); + + _.defaults( this.options, { + selection: [], + library: {}, + multiple: false, + state: 'library' + }); + + this.createSelection(); + this.createStates(); + this.bindHandlers(); + }, + + createSelection: function() { + var controller = this, + selection = this.options.selection; + + if ( ! (selection instanceof media.model.Selection) ) { + this.options.selection = new media.model.Selection( selection, { + multiple: this.options.multiple + }); + } + + this._selection = { + attachments: new Attachments(), + difference: [] + }; + }, + + createStates: function() { + var options = this.options; + + if ( this.options.states ) + return; + + // Add the default states. + this.states.add([ + // Main states. + new media.controller.Library({ + library: media.query( options.library ), + multiple: options.multiple, + title: options.title, + priority: 20 + }) + ]); + }, + + bindHandlers: function() { + this.on( 'router:create:browse', this.createRouter, this ); + this.on( 'router:render:browse', this.browseRouter, this ); + this.on( 'content:create:browse', this.browseContent, this ); + this.on( 'content:render:upload', this.uploadContent, this ); + this.on( 'toolbar:create:select', this.createSelectToolbar, this ); + }, + + // Routers + browseRouter: function( view ) { + view.set({ + upload: { + text: l10n.uploadFilesTitle, + priority: 20 + }, + browse: { + text: l10n.mediaLibraryTitle, + priority: 40 + } + }); + }, + + // Content + browseContent: function( content ) { + var state = this.state(); + + this.$el.removeClass('hide-toolbar'); + + // Browse our library of attachments. + content.view = new media.view.AttachmentsBrowser({ + controller: this, + collection: state.get('library'), + selection: state.get('selection'), + model: state, + sortable: state.get('sortable'), + search: state.get('searchable'), + filters: state.get('filterable'), + display: state.get('displaySettings'), + dragInfo: state.get('dragInfo'), + + AttachmentView: state.get('AttachmentView') + }); + }, + + uploadContent: function() { + this.$el.removeClass('hide-toolbar'); + this.content.set( new media.view.UploaderInline({ + controller: this + }) ); + }, + + // Toolbars + createSelectToolbar: function( toolbar, options ) { + options = options || this.options.button || {}; + options.controller = this; + + toolbar.view = new media.view.Toolbar.Select( options ); + } + }); + + /** + * wp.media.view.MediaFrame.Post + */ + media.view.MediaFrame.Post = media.view.MediaFrame.Select.extend({ + initialize: function() { + _.defaults( this.options, { + multiple: true, + editing: false, + state: 'insert' + }); + + media.view.MediaFrame.Select.prototype.initialize.apply( this, arguments ); + this.createIframeStates(); + }, + + createStates: function() { + var options = this.options; + + // Add the default states. + this.states.add([ + // Main states. + new media.controller.Library({ + id: 'insert', + title: l10n.insertMediaTitle, + priority: 20, + toolbar: 'main-insert', + filterable: 'all', + library: media.query( options.library ), + multiple: options.multiple ? 'reset' : false, + editable: true, + + // If the user isn't allowed to edit fields, + // can they still edit it locally? + allowLocalEdits: true, + + // Show the attachment display settings. + displaySettings: true, + // Update user settings when users adjust the + // attachment display settings. + displayUserSettings: true + }), + + new media.controller.Library({ + id: 'gallery', + title: l10n.createGalleryTitle, + priority: 40, + toolbar: 'main-gallery', + filterable: 'uploaded', + multiple: 'add', + editable: false, + + library: media.query( _.defaults({ + type: 'image' + }, options.library ) ) + }), + + // Embed states. + new media.controller.Embed(), + + // Gallery states. + new media.controller.GalleryEdit({ + library: options.selection, + editing: options.editing, + menu: 'gallery' + }), + + new media.controller.GalleryAdd() + ]); + + + if ( media.view.settings.post.featuredImageId ) { + this.states.add( new media.controller.FeaturedImage() ); + } + }, + + bindHandlers: function() { + media.view.MediaFrame.Select.prototype.bindHandlers.apply( this, arguments ); + this.on( 'menu:create:gallery', this.createMenu, this ); + this.on( 'toolbar:create:main-insert', this.createToolbar, this ); + this.on( 'toolbar:create:main-gallery', this.createToolbar, this ); + this.on( 'toolbar:create:featured-image', this.featuredImageToolbar, this ); + this.on( 'toolbar:create:main-embed', this.mainEmbedToolbar, this ); + + var handlers = { + menu: { + 'default': 'mainMenu', + 'gallery': 'galleryMenu' + }, + + content: { + 'embed': 'embedContent', + 'edit-selection': 'editSelectionContent' + }, + + toolbar: { + 'main-insert': 'mainInsertToolbar', + 'main-gallery': 'mainGalleryToolbar', + 'gallery-edit': 'galleryEditToolbar', + 'gallery-add': 'galleryAddToolbar' + } + }; + + _.each( handlers, function( regionHandlers, region ) { + _.each( regionHandlers, function( callback, handler ) { + this.on( region + ':render:' + handler, this[ callback ], this ); + }, this ); + }, this ); + }, + + // Menus + mainMenu: function( view ) { + view.set({ + 'library-separator': new media.View({ + className: 'separator', + priority: 100 + }) + }); + }, + + galleryMenu: function( view ) { + var lastState = this.lastState(), + previous = lastState && lastState.id, + frame = this; + + view.set({ + cancel: { + text: l10n.cancelGalleryTitle, + priority: 20, + click: function() { + if ( previous ) + frame.setState( previous ); + else + frame.close(); + } + }, + separateCancel: new media.View({ + className: 'separator', + priority: 40 + }) + }); + }, + + // Content + embedContent: function() { + var view = new media.view.Embed({ + controller: this, + model: this.state() + }).render(); + + this.content.set( view ); + view.url.focus(); + }, + + editSelectionContent: function() { + var state = this.state(), + selection = state.get('selection'), + view; + + view = new media.view.AttachmentsBrowser({ + controller: this, + collection: selection, + selection: selection, + model: state, + sortable: true, + search: false, + dragInfo: true, + + AttachmentView: media.view.Attachment.EditSelection + }).render(); + + view.toolbar.set( 'backToLibrary', { + text: l10n.returnToLibrary, + priority: -100, + + click: function() { + this.controller.content.mode('browse'); + } + }); + + // Browse our library of attachments. + this.content.set( view ); + }, + + // Toolbars + selectionStatusToolbar: function( view ) { + var editable = this.state().get('editable'); + + view.set( 'selection', new media.view.Selection({ + controller: this, + collection: this.state().get('selection'), + priority: -40, + + // If the selection is editable, pass the callback to + // switch the content mode. + editable: editable && function() { + this.controller.content.mode('edit-selection'); + } + }).render() ); + }, + + mainInsertToolbar: function( view ) { + var controller = this; + + this.selectionStatusToolbar( view ); + + view.set( 'insert', { + style: 'primary', + priority: 80, + text: l10n.insertIntoPost, + requires: { selection: true }, + + click: function() { + var state = controller.state(), + selection = state.get('selection'); + + controller.close(); + state.trigger( 'insert', selection ).reset(); + } + }); + }, + + mainGalleryToolbar: function( view ) { + var controller = this; + + this.selectionStatusToolbar( view ); + + view.set( 'gallery', { + style: 'primary', + text: l10n.createNewGallery, + priority: 60, + requires: { selection: true }, + + click: function() { + var selection = controller.state().get('selection'), + edit = controller.state('gallery-edit'), + models = selection.where({ type: 'image' }); + + edit.set( 'library', new media.model.Selection( models, { + props: selection.props.toJSON(), + multiple: true + }) ); + + this.controller.setState('gallery-edit'); + } + }); + }, + + featuredImageToolbar: function( toolbar ) { + this.createSelectToolbar( toolbar, { + text: l10n.setFeaturedImage, + state: this.options.state || 'upload' + }); + }, + + mainEmbedToolbar: function( toolbar ) { + toolbar.view = new media.view.Toolbar.Embed({ + controller: this + }); + }, + + galleryEditToolbar: function() { + var editing = this.state().get('editing'); + this.toolbar.set( new media.view.Toolbar({ + controller: this, + items: { + insert: { + style: 'primary', + text: editing ? l10n.updateGallery : l10n.insertGallery, + priority: 80, + requires: { library: true }, + + click: function() { + var controller = this.controller, + state = controller.state(); + + controller.close(); + state.trigger( 'update', state.get('library') ); + + controller.reset(); + // @todo: Make the state activated dynamic (instead of hardcoded). + controller.setState('upload'); + } + } + } + }) ); + }, + + galleryAddToolbar: function() { + this.toolbar.set( new media.view.Toolbar({ + controller: this, + items: { + insert: { + style: 'primary', + text: l10n.addToGallery, + priority: 80, + requires: { selection: true }, + + click: function() { + var controller = this.controller, + state = controller.state(), + edit = controller.state('gallery-edit'); + + edit.get('library').add( state.get('selection').models ); + state.trigger('reset'); + controller.setState('gallery-edit'); + } + } + } + }) ); + } + }); + + /** + * wp.media.view.Modal + */ + media.view.Modal = media.View.extend({ + tagName: 'div', + template: media.template('media-modal'), + + attributes: { + tabindex: 0 + }, + + events: { + 'click .media-modal-backdrop, .media-modal-close': 'escapeHandler', + 'keydown': 'keydown' + }, + + initialize: function() { + _.defaults( this.options, { + container: document.body, + title: '', + propagate: true, + freeze: true + }); + }, + + prepare: function() { + return { + title: this.options.title + }; + }, + + attach: function() { + if ( this.views.attached ) + return this; + + if ( ! this.views.rendered ) + this.render(); + + this.$el.appendTo( this.options.container ); + + // Manually mark the view as attached and trigger ready. + this.views.attached = true; + this.views.ready(); + + return this.propagate('attach'); + }, + + detach: function() { + if ( this.$el.is(':visible') ) + this.close(); + + this.$el.detach(); + this.views.attached = false; + return this.propagate('detach'); + }, + + open: function() { + var $el = this.$el, + options = this.options; + + if ( $el.is(':visible') ) + return this; + + if ( ! this.views.attached ) + this.attach(); + + // If the `freeze` option is set, record the window's scroll position. + if ( options.freeze ) { + this._freeze = { + scrollTop: $( window ).scrollTop() + }; + } + + $el.show().focus(); + return this.propagate('open'); + }, + + close: function( options ) { + var freeze = this._freeze; + + if ( ! this.views.attached || ! this.$el.is(':visible') ) + return this; + + this.$el.hide(); + this.propagate('close'); + + // If the `freeze` option is set, restore the container's scroll position. + if ( freeze ) { + $( window ).scrollTop( freeze.scrollTop ); + } + + if ( options && options.escape ) + this.propagate('escape'); + + return this; + }, + + escape: function() { + return this.close({ escape: true }); + }, + + escapeHandler: function( event ) { + event.preventDefault(); + this.escape(); + }, + + content: function( content ) { + this.views.set( '.media-modal-content', content ); + return this; + }, + + // Triggers a modal event and if the `propagate` option is set, + // forwards events to the modal's controller. + propagate: function( id ) { + this.trigger( id ); + + if ( this.options.propagate ) + this.controller.trigger( id ); + + return this; + }, + + keydown: function( event ) { + // Close the modal when escape is pressed. + if ( 27 === event.which ) { + event.preventDefault(); + this.escape(); + return; + } + } + }); + + // wp.media.view.FocusManager + // ---------------------------- + media.view.FocusManager = media.View.extend({ + events: { + keydown: 'recordTab', + focusin: 'updateIndex' + }, + + focus: function() { + if ( _.isUndefined( this.index ) ) + return; + + // Update our collection of `$tabbables`. + this.$tabbables = this.$(':tabbable'); + + // If tab is saved, focus it. + this.$tabbables.eq( this.index ).focus(); + }, + + recordTab: function( event ) { + // Look for the tab key. + if ( 9 !== event.keyCode ) + return; + + // First try to update the index. + if ( _.isUndefined( this.index ) ) + this.updateIndex( event ); + + // If we still don't have an index, bail. + if ( _.isUndefined( this.index ) ) + return; + + var index = this.index + ( event.shiftKey ? -1 : 1 ); + + if ( index >= 0 && index < this.$tabbables.length ) + this.index = index; + else + delete this.index; + }, + + updateIndex: function( event ) { + this.$tabbables = this.$(':tabbable'); + + var index = this.$tabbables.index( event.target ); + + if ( -1 === index ) + delete this.index; + else + this.index = index; + } + }); + + // wp.media.view.UploaderWindow + // ---------------------------- + media.view.UploaderWindow = media.View.extend({ + tagName: 'div', + className: 'uploader-window', + template: media.template('uploader-window'), + + initialize: function() { + var uploader; + + this.$browser = $('').hide().appendTo('body'); + + uploader = this.options.uploader = _.defaults( this.options.uploader || {}, { + dropzone: this.$el, + browser: this.$browser, + params: {} + }); + + // Ensure the dropzone is a jQuery collection. + if ( uploader.dropzone && ! (uploader.dropzone instanceof $) ) + uploader.dropzone = $( uploader.dropzone ); + + this.controller.on( 'activate', this.refresh, this ); + }, + + refresh: function() { + if ( this.uploader ) + this.uploader.refresh(); + }, + + ready: function() { + var postId = media.view.settings.post.id, + dropzone; + + // If the uploader already exists, bail. + if ( this.uploader ) + return; + + if ( postId ) + this.options.uploader.params.post_id = postId; + + this.uploader = new wp.Uploader( this.options.uploader ); + + dropzone = this.uploader.dropzone; + dropzone.on( 'dropzone:enter', _.bind( this.show, this ) ); + dropzone.on( 'dropzone:leave', _.bind( this.hide, this ) ); + }, + + show: function() { + var $el = this.$el.show(); + + // Ensure that the animation is triggered by waiting until + // the transparent element is painted into the DOM. + _.defer( function() { + $el.css({ opacity: 1 }); + }); + }, + + hide: function() { + var $el = this.$el.css({ opacity: 0 }); + + media.transition( $el ).done( function() { + // Transition end events are subject to race conditions. + // Make sure that the value is set as intended. + if ( '0' === $el.css('opacity') ) + $el.hide(); + }); + } + }); + + media.view.UploaderInline = media.View.extend({ + tagName: 'div', + className: 'uploader-inline', + template: media.template('uploader-inline'), + + initialize: function() { + _.defaults( this.options, { + message: '', + status: true + }); + + if ( ! this.options.$browser && this.controller.uploader ) + this.options.$browser = this.controller.uploader.$browser; + + if ( _.isUndefined( this.options.postId ) ) + this.options.postId = media.view.settings.post.id; + + if ( this.options.status ) { + this.views.set( '.upload-inline-status', new media.view.UploaderStatus({ + controller: this.controller + }) ); + } + }, + + dispose: function() { + if ( this.disposing ) + return media.View.prototype.dispose.apply( this, arguments ); + + // Run remove on `dispose`, so we can be sure to refresh the + // uploader with a view-less DOM. Track whether we're disposing + // so we don't trigger an infinite loop. + this.disposing = true; + return this.remove(); + }, + + remove: function() { + var result = media.View.prototype.remove.apply( this, arguments ); + + _.defer( _.bind( this.refresh, this ) ); + return result; + }, + + refresh: function() { + var uploader = this.controller.uploader; + + if ( uploader ) + uploader.refresh(); + }, + + ready: function() { + var $browser = this.options.$browser, + $placeholder; + + if ( this.controller.uploader ) { + $placeholder = this.$('.browser'); + + // Check if we've already replaced the placeholder. + if ( $placeholder[0] === $browser[0] ) + return; + + $browser.detach().text( $placeholder.text() ); + $browser[0].className = $placeholder[0].className; + $placeholder.replaceWith( $browser.show() ); + } + + this.refresh(); + return this; + } + }); + + /** + * wp.media.view.UploaderStatus + */ + media.view.UploaderStatus = media.View.extend({ + className: 'media-uploader-status', + template: media.template('uploader-status'), + + events: { + 'click .upload-dismiss-errors': 'dismiss' + }, + + initialize: function() { + this.queue = wp.Uploader.queue; + this.queue.on( 'add remove reset', this.visibility, this ); + this.queue.on( 'add remove reset change:percent', this.progress, this ); + this.queue.on( 'add remove reset change:uploading', this.info, this ); + + this.errors = wp.Uploader.errors; + this.errors.reset(); + this.errors.on( 'add remove reset', this.visibility, this ); + this.errors.on( 'add', this.error, this ); + }, + + dispose: function() { + wp.Uploader.queue.off( null, null, this ); + media.View.prototype.dispose.apply( this, arguments ); + return this; + }, + + visibility: function() { + this.$el.toggleClass( 'uploading', !! this.queue.length ); + this.$el.toggleClass( 'errors', !! this.errors.length ); + this.$el.toggle( !! this.queue.length || !! this.errors.length ); + }, + + ready: function() { + _.each({ + '$bar': '.media-progress-bar div', + '$index': '.upload-index', + '$total': '.upload-total', + '$filename': '.upload-filename' + }, function( selector, key ) { + this[ key ] = this.$( selector ); + }, this ); + + this.visibility(); + this.progress(); + this.info(); + }, + + progress: function() { + var queue = this.queue, + $bar = this.$bar, + memo = 0; + + if ( ! $bar || ! queue.length ) + return; + + $bar.width( ( queue.reduce( function( memo, attachment ) { + if ( ! attachment.get('uploading') ) + return memo + 100; + + var percent = attachment.get('percent'); + return memo + ( _.isNumber( percent ) ? percent : 100 ); + }, 0 ) / queue.length ) + '%' ); + }, + + info: function() { + var queue = this.queue, + index = 0, active; + + if ( ! queue.length ) + return; + + active = this.queue.find( function( attachment, i ) { + index = i; + return attachment.get('uploading'); + }); + + this.$index.text( index + 1 ); + this.$total.text( queue.length ); + this.$filename.html( active ? this.filename( active.get('filename') ) : '' ); + }, + + filename: function( filename ) { + return media.truncate( _.escape( filename ), 24 ); + }, + + error: function( error ) { + this.views.add( '.upload-errors', new media.view.UploaderStatusError({ + filename: this.filename( error.get('file').name ), + message: error.get('message') + }), { at: 0 }); + }, + + dismiss: function( event ) { + var errors = this.views.get('.upload-errors'); + + event.preventDefault(); + + if ( errors ) + _.invoke( errors, 'remove' ); + wp.Uploader.errors.reset(); + } + }); + + media.view.UploaderStatusError = media.View.extend({ + className: 'upload-error', + template: media.template('uploader-status-error') + }); + + /** + * wp.media.view.Toolbar + */ + media.view.Toolbar = media.View.extend({ + tagName: 'div', + className: 'media-toolbar', + + initialize: function() { + var state = this.controller.state(), + selection = this.selection = state.get('selection'), + library = this.library = state.get('library'); + + this._views = {}; + + // The toolbar is composed of two `PriorityList` views. + this.primary = new media.view.PriorityList(); + this.secondary = new media.view.PriorityList(); + this.primary.$el.addClass('media-toolbar-primary'); + this.secondary.$el.addClass('media-toolbar-secondary'); + + this.views.set([ this.secondary, this.primary ]); + + if ( this.options.items ) + this.set( this.options.items, { silent: true }); + + if ( ! this.options.silent ) + this.render(); + + if ( selection ) + selection.on( 'add remove reset', this.refresh, this ); + if ( library ) + library.on( 'add remove reset', this.refresh, this ); + }, + + dispose: function() { + if ( this.selection ) + this.selection.off( null, null, this ); + if ( this.library ) + this.library.off( null, null, this ); + return media.View.prototype.dispose.apply( this, arguments ); + }, + + ready: function() { + this.refresh(); + }, + + set: function( id, view, options ) { + var list; + options = options || {}; + + // Accept an object with an `id` : `view` mapping. + if ( _.isObject( id ) ) { + _.each( id, function( view, id ) { + this.set( id, view, { silent: true }); + }, this ); + + } else { + if ( ! ( view instanceof Backbone.View ) ) { + view.classes = [ 'media-button-' + id ].concat( view.classes || [] ); + view = new media.view.Button( view ).render(); + } + + view.controller = view.controller || this.controller; + + this._views[ id ] = view; + + list = view.options.priority < 0 ? 'secondary' : 'primary'; + this[ list ].set( id, view, options ); + } + + if ( ! options.silent ) + this.refresh(); + + return this; + }, + + get: function( id ) { + return this._views[ id ]; + }, + + unset: function( id, options ) { + delete this._views[ id ]; + this.primary.unset( id, options ); + this.secondary.unset( id, options ); + + if ( ! options || ! options.silent ) + this.refresh(); + return this; + }, + + refresh: function() { + var state = this.controller.state(), + library = state.get('library'), + selection = state.get('selection'); + + _.each( this._views, function( button ) { + if ( ! button.model || ! button.options || ! button.options.requires ) + return; + + var requires = button.options.requires, + disabled = false; + + if ( requires.selection && selection && ! selection.length ) + disabled = true; + else if ( requires.library && library && ! library.length ) + disabled = true; + + button.model.set( 'disabled', disabled ); + }); + } + }); + + // wp.media.view.Toolbar.Select + // ---------------------------- + media.view.Toolbar.Select = media.view.Toolbar.extend({ + initialize: function() { + var options = this.options, + controller = options.controller, + selection = controller.state().get('selection'); + + _.bindAll( this, 'clickSelect' ); + + _.defaults( options, { + event: 'select', + state: false, + reset: true, + close: true, + text: l10n.select, + + // Does the button rely on the selection? + requires: { + selection: true + } + }); + + options.items = _.defaults( options.items || {}, { + select: { + style: 'primary', + text: options.text, + priority: 80, + click: this.clickSelect, + requires: options.requires + } + }); + + media.view.Toolbar.prototype.initialize.apply( this, arguments ); + }, + + clickSelect: function() { + var options = this.options, + controller = this.controller; + + if ( options.close ) + controller.close(); + + if ( options.event ) + controller.state().trigger( options.event ); + + if ( options.reset ) + controller.reset(); + + if ( options.state ) + controller.setState( options.state ); + } + }); + + // wp.media.view.Toolbar.Embed + // --------------------------- + media.view.Toolbar.Embed = media.view.Toolbar.Select.extend({ + initialize: function() { + _.defaults( this.options, { + text: l10n.insertIntoPost, + requires: false + }); + + media.view.Toolbar.Select.prototype.initialize.apply( this, arguments ); + }, + + refresh: function() { + var url = this.controller.state().props.get('url'); + this.get('select').model.set( 'disabled', ! url || url === 'http://' ); + + media.view.Toolbar.Select.prototype.refresh.apply( this, arguments ); + } + }); + + /** + * wp.media.view.Button + */ + media.view.Button = media.View.extend({ + tagName: 'a', + className: 'media-button', + attributes: { href: '#' }, + + events: { + 'click': 'click' + }, + + defaults: { + text: '', + style: '', + size: 'large', + disabled: false + }, + + initialize: function() { + // Create a model with the provided `defaults`. + this.model = new Backbone.Model( this.defaults ); + + // If any of the `options` have a key from `defaults`, apply its + // value to the `model` and remove it from the `options object. + _.each( this.defaults, function( def, key ) { + var value = this.options[ key ]; + if ( _.isUndefined( value ) ) + return; + + this.model.set( key, value ); + delete this.options[ key ]; + }, this ); + + this.model.on( 'change', this.render, this ); + }, + + render: function() { + var classes = [ 'button', this.className ], + model = this.model.toJSON(); + + if ( model.style ) + classes.push( 'button-' + model.style ); + + if ( model.size ) + classes.push( 'button-' + model.size ); + + classes = _.uniq( classes.concat( this.options.classes ) ); + this.el.className = classes.join(' '); + + this.$el.attr( 'disabled', model.disabled ); + this.$el.text( this.model.get('text') ); + + return this; + }, + + click: function( event ) { + if ( '#' === this.attributes.href ) + event.preventDefault(); + + if ( this.options.click && ! this.model.get('disabled') ) + this.options.click.apply( this, arguments ); + } + }); + + /** + * wp.media.view.ButtonGroup + */ + media.view.ButtonGroup = media.View.extend({ + tagName: 'div', + className: 'button-group button-large media-button-group', + + initialize: function() { + this.buttons = _.map( this.options.buttons || [], function( button ) { + if ( button instanceof Backbone.View ) + return button; + else + return new media.view.Button( button ).render(); + }); + + delete this.options.buttons; + + if ( this.options.classes ) + this.$el.addClass( this.options.classes ); + }, + + render: function() { + this.$el.html( $( _.pluck( this.buttons, 'el' ) ).detach() ); + return this; + } + }); + + /** + * wp.media.view.PriorityList + */ + + media.view.PriorityList = media.View.extend({ + tagName: 'div', + + initialize: function() { + this._views = {}; + + this.set( _.extend( {}, this._views, this.options.views ), { silent: true }); + delete this.options.views; + + if ( ! this.options.silent ) + this.render(); + }, + + set: function( id, view, options ) { + var priority, views, index; + + options = options || {}; + + // Accept an object with an `id` : `view` mapping. + if ( _.isObject( id ) ) { + _.each( id, function( view, id ) { + this.set( id, view ); + }, this ); + return this; + } + + if ( ! (view instanceof Backbone.View) ) + view = this.toView( view, id, options ); + + view.controller = view.controller || this.controller; + + this.unset( id ); + + priority = view.options.priority || 10; + views = this.views.get() || []; + + _.find( views, function( existing, i ) { + if ( existing.options.priority > priority ) { + index = i; + return true; + } + }); + + this._views[ id ] = view; + this.views.add( view, { + at: _.isNumber( index ) ? index : views.length || 0 + }); + + return this; + }, + + get: function( id ) { + return this._views[ id ]; + }, + + unset: function( id ) { + var view = this.get( id ); + + if ( view ) + view.remove(); + + delete this._views[ id ]; + return this; + }, + + toView: function( options ) { + return new media.View( options ); + } + }); + + /** + * wp.media.view.MenuItem + */ + media.view.MenuItem = media.View.extend({ + tagName: 'a', + className: 'media-menu-item', + + attributes: { + href: '#' + }, + + events: { + 'click': '_click' + }, + + _click: function( event ) { + var clickOverride = this.options.click; + + if ( event ) + event.preventDefault(); + + if ( clickOverride ) + clickOverride.call( this ); + else + this.click(); + }, + + click: function() { + var state = this.options.state; + if ( state ) + this.controller.setState( state ); + }, + + render: function() { + var options = this.options; + + if ( options.text ) + this.$el.text( options.text ); + else if ( options.html ) + this.$el.html( options.html ); + + return this; + } + }); + + /** + * wp.media.view.Menu + */ + media.view.Menu = media.view.PriorityList.extend({ + tagName: 'div', + className: 'media-menu', + property: 'state', + ItemView: media.view.MenuItem, + region: 'menu', + + toView: function( options, id ) { + options = options || {}; + options[ this.property ] = options[ this.property ] || id; + return new this.ItemView( options ).render(); + }, + + ready: function() { + media.view.PriorityList.prototype.ready.apply( this, arguments ); + this.visibility(); + }, + + set: function() { + media.view.PriorityList.prototype.set.apply( this, arguments ); + this.visibility(); + }, + + unset: function() { + media.view.PriorityList.prototype.unset.apply( this, arguments ); + this.visibility(); + }, + + visibility: function() { + var region = this.region, + view = this.controller[ region ].get(), + views = this.views.get(), + hide = ! views || views.length < 2; + + if ( this === view ) + this.controller.$el.toggleClass( 'hide-' + region, hide ); + }, + + select: function( id ) { + var view = this.get( id ); + + if ( ! view ) + return; + + this.deselect(); + view.$el.addClass('active'); + }, + + deselect: function() { + this.$el.children().removeClass('active'); + } + }); + + /** + * wp.media.view.RouterItem + */ + media.view.RouterItem = media.view.MenuItem.extend({ + click: function() { + var contentMode = this.options.contentMode; + if ( contentMode ) + this.controller.content.mode( contentMode ); + } + }); + + /** + * wp.media.view.Router + */ + media.view.Router = media.view.Menu.extend({ + tagName: 'div', + className: 'media-router', + property: 'contentMode', + ItemView: media.view.RouterItem, + region: 'router', + + initialize: function() { + this.controller.on( 'content:render', this.update, this ); + media.view.Menu.prototype.initialize.apply( this, arguments ); + }, + + update: function() { + var mode = this.controller.content.mode(); + if ( mode ) + this.select( mode ); + } + }); + + + /** + * wp.media.view.Sidebar + */ + media.view.Sidebar = media.view.PriorityList.extend({ + className: 'media-sidebar' + }); + + /** + * wp.media.view.Attachment + */ + media.view.Attachment = media.View.extend({ + tagName: 'li', + className: 'attachment', + template: media.template('attachment'), + + events: { + 'click .attachment-preview': 'toggleSelectionHandler', + 'change [data-setting]': 'updateSetting', + 'change [data-setting] input': 'updateSetting', + 'change [data-setting] select': 'updateSetting', + 'change [data-setting] textarea': 'updateSetting', + 'click .close': 'removeFromLibrary', + 'click .check': 'removeFromSelection', + 'click a': 'preventDefault' + }, + + buttons: {}, + + initialize: function() { + var selection = this.options.selection; + + this.model.on( 'change:sizes change:uploading change:caption change:title', this.render, this ); + this.model.on( 'change:percent', this.progress, this ); + + // Update the selection. + this.model.on( 'add', this.select, this ); + this.model.on( 'remove', this.deselect, this ); + if ( selection ) + selection.on( 'reset', this.updateSelect, this ); + + // Update the model's details view. + this.model.on( 'selection:single selection:unsingle', this.details, this ); + this.details( this.model, this.controller.state().get('selection') ); + }, + + dispose: function() { + var selection = this.options.selection; + + // Make sure all settings are saved before removing the view. + this.updateAll(); + + if ( selection ) + selection.off( null, null, this ); + + media.View.prototype.dispose.apply( this, arguments ); + return this; + }, + + render: function() { + var options = _.defaults( this.model.toJSON(), { + orientation: 'landscape', + uploading: false, + type: '', + subtype: '', + icon: '', + filename: '', + caption: '', + title: '', + dateFormatted: '', + width: '', + height: '', + compat: false, + alt: '', + description: '' + }); + + options.buttons = this.buttons; + options.describe = this.controller.state().get('describe'); + + if ( 'image' === options.type ) + options.size = this.imageSize(); + + options.can = {}; + if ( options.nonces ) { + options.can.remove = !! options.nonces['delete']; + options.can.save = !! options.nonces.update; + } + + if ( this.controller.state().get('allowLocalEdits') ) + options.allowLocalEdits = true; + + this.views.detach(); + this.$el.html( this.template( options ) ); + + this.$el.toggleClass( 'uploading', options.uploading ); + if ( options.uploading ) + this.$bar = this.$('.media-progress-bar div'); + else + delete this.$bar; + + // Check if the model is selected. + this.updateSelect(); + + // Update the save status. + this.updateSave(); + + this.views.render(); + + return this; + }, + + progress: function() { + if ( this.$bar && this.$bar.length ) + this.$bar.width( this.model.get('percent') + '%' ); + }, + + toggleSelectionHandler: function( event ) { + var method; + + if ( event.shiftKey ) + method = 'between'; + else if ( event.ctrlKey || event.metaKey ) + method = 'toggle'; + + this.toggleSelection({ + method: method + }); + }, + + toggleSelection: function( options ) { + var collection = this.collection, + selection = this.options.selection, + model = this.model, + method = options && options.method, + single, between, models, singleIndex, modelIndex; + + if ( ! selection ) + return; + + single = selection.single(); + method = _.isUndefined( method ) ? selection.multiple : method; + + // If the `method` is set to `between`, select all models that + // exist between the current and the selected model. + if ( 'between' === method && single && selection.multiple ) { + // If the models are the same, short-circuit. + if ( single === model ) + return; + + singleIndex = collection.indexOf( single ); + modelIndex = collection.indexOf( this.model ); + + if ( singleIndex < modelIndex ) + models = collection.models.slice( singleIndex, modelIndex + 1 ); + else + models = collection.models.slice( modelIndex, singleIndex + 1 ); + + selection.add( models ).single( model ); + return; + + // If the `method` is set to `toggle`, just flip the selection + // status, regardless of whether the model is the single model. + } else if ( 'toggle' === method ) { + selection[ this.selected() ? 'remove' : 'add' ]( model ).single( model ); + return; + } + + if ( method !== 'add' ) + method = 'reset'; + + if ( this.selected() ) { + // If the model is the single model, remove it. + // If it is not the same as the single model, + // it now becomes the single model. + selection[ single === model ? 'remove' : 'single' ]( model ); + } else { + // If the model is not selected, run the `method` on the + // selection. By default, we `reset` the selection, but the + // `method` can be set to `add` the model to the selection. + selection[ method ]( model ).single( model ); + } + }, + + updateSelect: function() { + this[ this.selected() ? 'select' : 'deselect' ](); + }, + + selected: function() { + var selection = this.options.selection; + if ( selection ) + return !! selection.getByCid( this.model.cid ); + }, + + select: function( model, collection ) { + var selection = this.options.selection; + + // Check if a selection exists and if it's the collection provided. + // If they're not the same collection, bail; we're in another + // selection's event loop. + if ( ! selection || ( collection && collection !== selection ) ) + return; + + this.$el.addClass('selected'); + }, + + deselect: function( model, collection ) { + var selection = this.options.selection; + + // Check if a selection exists and if it's the collection provided. + // If they're not the same collection, bail; we're in another + // selection's event loop. + if ( ! selection || ( collection && collection !== selection ) ) + return; + + this.$el.removeClass('selected'); + }, + + details: function( model, collection ) { + var selection = this.options.selection, + details; + + if ( selection !== collection ) + return; + + details = selection.single(); + this.$el.toggleClass( 'details', details === this.model ); + }, + + preventDefault: function( event ) { + event.preventDefault(); + }, + + imageSize: function( size ) { + var sizes = this.model.get('sizes'); + + size = size || 'medium'; + + // Use the provided image size if possible. + if ( sizes && sizes[ size ] ) { + return _.clone( sizes[ size ] ); + } else { + return { + url: this.model.get('url'), + width: this.model.get('width'), + height: this.model.get('height'), + orientation: this.model.get('orientation') + }; + } + }, + + updateSetting: function( event ) { + var $setting = $( event.target ).closest('[data-setting]'), + setting, value; + + if ( ! $setting.length ) + return; + + setting = $setting.data('setting'); + value = event.target.value; + + if ( this.model.get( setting ) !== value ) + this.save( setting, value ); + }, + + // Pass all the arguments to the model's save method. + // + // Records the aggregate status of all save requests and updates the + // view's classes accordingly. + save: function() { + var view = this, + save = this._save = this._save || { status: 'ready' }, + request = this.model.save.apply( this.model, arguments ), + requests = save.requests ? $.when( request, save.requests ) : request; + + // If we're waiting to remove 'Saved.', stop. + if ( save.savedTimer ) + clearTimeout( save.savedTimer ); + + this.updateSave('waiting'); + save.requests = requests; + requests.always( function() { + // If we've performed another request since this one, bail. + if ( save.requests !== requests ) + return; + + view.updateSave( requests.state() === 'resolved' ? 'complete' : 'error' ); + save.savedTimer = setTimeout( function() { + view.updateSave('ready'); + delete save.savedTimer; + }, 2000 ); + }); + + }, + + updateSave: function( status ) { + var save = this._save = this._save || { status: 'ready' }; + + if ( status && status !== save.status ) { + this.$el.removeClass( 'save-' + save.status ); + save.status = status; + } + + this.$el.addClass( 'save-' + save.status ); + return this; + }, + + updateAll: function() { + var $settings = this.$('[data-setting]'), + model = this.model, + changed; + + changed = _.chain( $settings ).map( function( el ) { + var $input = $('input, textarea, select, [value]', el ), + setting, value; + + if ( ! $input.length ) + return; + + setting = $(el).data('setting'); + value = $input.val(); + + // Record the value if it changed. + if ( model.get( setting ) !== value ) + return [ setting, value ]; + }).compact().object().value(); + + if ( ! _.isEmpty( changed ) ) + model.save( changed ); + }, + + removeFromLibrary: function( event ) { + // Stop propagation so the model isn't selected. + event.stopPropagation(); + + this.collection.remove( this.model ); + }, + + removeFromSelection: function( event ) { + var selection = this.options.selection; + if ( ! selection ) + return; + + // Stop propagation so the model isn't selected. + event.stopPropagation(); + + selection.remove( this.model ); + } + }); + + /** + * wp.media.view.Attachment.Library + */ + media.view.Attachment.Library = media.view.Attachment.extend({ + buttons: { + check: true + } + }); + + /** + * wp.media.view.Attachment.EditLibrary + */ + media.view.Attachment.EditLibrary = media.view.Attachment.extend({ + buttons: { + close: true + } + }); + + /** + * wp.media.view.Attachments + */ + media.view.Attachments = media.View.extend({ + tagName: 'ul', + className: 'attachments', + + cssTemplate: media.template('attachments-css'), + + events: { + 'scroll': 'scroll' + }, + + initialize: function() { + this.el.id = _.uniqueId('__attachments-view-'); + + _.defaults( this.options, { + refreshSensitivity: 200, + refreshThreshold: 3, + AttachmentView: media.view.Attachment, + sortable: false, + resize: true + }); + + this._viewsByCid = {}; + + this.collection.on( 'add', function( attachment, attachments, options ) { + this.views.add( this.createAttachmentView( attachment ), { + at: options.index + }); + }, this ); + + this.collection.on( 'remove', function( attachment, attachments, options ) { + var view = this._viewsByCid[ attachment.cid ]; + delete this._viewsByCid[ attachment.cid ]; + + if ( view ) + view.remove(); + }, this ); + + this.collection.on( 'reset', this.render, this ); + + // Throttle the scroll handler. + this.scroll = _.chain( this.scroll ).bind( this ).throttle( this.options.refreshSensitivity ).value(); + + this.initSortable(); + + _.bindAll( this, 'css' ); + this.model.on( 'change:edge change:gutter', this.css, this ); + this._resizeCss = _.debounce( _.bind( this.css, this ), this.refreshSensitivity ); + if ( this.options.resize ) + $(window).on( 'resize.attachments', this._resizeCss ); + this.css(); + }, + + dispose: function() { + this.collection.props.off( null, null, this ); + $(window).off( 'resize.attachments', this._resizeCss ); + media.View.prototype.dispose.apply( this, arguments ); + }, + + css: function() { + var $css = $( '#' + this.el.id + '-css' ); + + if ( $css.length ) + $css.remove(); + + media.view.Attachments.$head().append( this.cssTemplate({ + id: this.el.id, + edge: this.edge(), + gutter: this.model.get('gutter') + }) ); + }, + + edge: function() { + var edge = this.model.get('edge'), + gutter, width, columns; + + if ( ! this.$el.is(':visible') ) + return edge; + + gutter = this.model.get('gutter') * 2; + width = this.$el.width() - gutter; + columns = Math.ceil( width / ( edge + gutter ) ); + edge = Math.floor( ( width - ( columns * gutter ) ) / columns ); + return edge; + }, + + initSortable: function() { + var collection = this.collection; + + if ( ! this.options.sortable || ! $.fn.sortable ) + return; + + this.$el.sortable( _.extend({ + // If the `collection` has a `comparator`, disable sorting. + disabled: !! collection.comparator, + + // Prevent attachments from being dragged outside the bounding + // box of the list. + containment: this.$el, + + // Change the position of the attachment as soon as the + // mouse pointer overlaps a thumbnail. + tolerance: 'pointer', + + // Record the initial `index` of the dragged model. + start: function( event, ui ) { + ui.item.data('sortableIndexStart', ui.item.index()); + }, + + // Update the model's index in the collection. + // Do so silently, as the view is already accurate. + update: function( event, ui ) { + var model = collection.at( ui.item.data('sortableIndexStart') ), + comparator = collection.comparator; + + // Temporarily disable the comparator to prevent `add` + // from re-sorting. + delete collection.comparator; + + // Silently shift the model to its new index. + collection.remove( model, { + silent: true + }).add( model, { + silent: true, + at: ui.item.index() + }); + + // Restore the comparator. + collection.comparator = comparator; + + // Fire the `reset` event to ensure other collections sync. + collection.trigger( 'reset', collection ); + + // If the collection is sorted by menu order, + // update the menu order. + collection.saveMenuOrder(); + } + }, this.options.sortable ) ); + + // If the `orderby` property is changed on the `collection`, + // check to see if we have a `comparator`. If so, disable sorting. + collection.props.on( 'change:orderby', function() { + this.$el.sortable( 'option', 'disabled', !! collection.comparator ); + }, this ); + + this.collection.props.on( 'change:orderby', this.refreshSortable, this ); + this.refreshSortable(); + }, + + refreshSortable: function() { + if ( ! this.options.sortable || ! $.fn.sortable ) + return; + + // If the `collection` has a `comparator`, disable sorting. + var collection = this.collection, + orderby = collection.props.get('orderby'), + enabled = 'menuOrder' === orderby || ! collection.comparator; + + this.$el.sortable( 'option', 'disabled', ! enabled ); + }, + + createAttachmentView: function( attachment ) { + var view = new this.options.AttachmentView({ + controller: this.controller, + model: attachment, + collection: this.collection, + selection: this.options.selection + }); + + return this._viewsByCid[ attachment.cid ] = view; + }, + + prepare: function() { + // Create all of the Attachment views, and replace + // the list in a single DOM operation. + if ( this.collection.length ) { + this.views.set( this.collection.map( this.createAttachmentView, this ) ); + + // If there are no elements, clear the views and load some. + } else { + this.views.unset(); + this.collection.more().done( this.scroll ); + } + }, + + ready: function() { + // Trigger the scroll event to check if we're within the + // threshold to query for additional attachments. + this.scroll(); + }, + + scroll: function( event ) { + // @todo: is this still necessary? + if ( ! this.$el.is(':visible') ) + return; + + if ( this.collection.hasMore() && this.el.scrollHeight < this.el.scrollTop + ( this.el.clientHeight * this.options.refreshThreshold ) ) { + this.collection.more().done( this.scroll ); + } + } + }, { + $head: (function() { + var $head; + return function() { + return $head = $head || $('head'); + }; + }()) + }); + + /** + * wp.media.view.Search + */ + media.view.Search = media.View.extend({ + tagName: 'input', + className: 'search', + + attributes: { + type: 'search', + placeholder: l10n.search + }, + + events: { + 'input': 'search', + 'keyup': 'search', + 'change': 'search', + 'search': 'search' + }, + + render: function() { + this.el.value = this.model.escape('search'); + return this; + }, + + search: function( event ) { + if ( event.target.value ) + this.model.set( 'search', event.target.value ); + else + this.model.unset('search'); + } + }); + + /** + * wp.media.view.AttachmentFilters + */ + media.view.AttachmentFilters = media.View.extend({ + tagName: 'select', + className: 'attachment-filters', + + events: { + change: 'change' + }, + + keys: [], + + initialize: function() { + this.createFilters(); + _.extend( this.filters, this.options.filters ); + + // Build `