(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 = _.isUndefined( _wpMediaViewsL10n ) ? {} : _wpMediaViewsL10n; /** * ======================================================================== * CONTROLLERS * ======================================================================== */ /** * wp.media.controller.Workflow */ media.controller.Workflow = Backbone.Model.extend({ defaults: { title: '', multiple: false, view: 'library', library: {}, selection: [] }, initialize: function() { this.createSelection(); // Initialize view storage. this._views = {}; this._pendingViews = {}; // Initialize modal container view. this.modal = new media.view.Modal({ controller: this }); // Add default views. // // Use the `library` property to initialize the corresponding view, // then unset the property. this.add( 'library', media.view.Workspace.Library, { collection: media.query( this.get('library') ) }); this.unset('library'); // Add the gallery view. this.add( 'gallery', media.view.Workspace.Gallery, { collection: this.selection }); this.add( 'gallery-library', media.view.Workspace.Library.Gallery, { collection: media.query({ type: 'image' }) }); }, // Registers a view. // // `id` is a unique ID for the view relative to the workflow instance. // `constructor` is a `Backbone.View` constructor. `options` are the // options to be passed when the view is initialized. // // Triggers the `add` and `add:VIEW_ID` events. add: function( id, constructor, options ) { this.remove( id ); this._pendingViews[ id ] = { view: constructor, options: options }; this.trigger( 'add add:' + id, constructor, options ); return this; }, // Returns a registered view instance. If an `id` is not provided, // it will return the active view. // // Lazily instantiates a registered view. // // Triggers the `init` and `init:VIEW_ID` events. view: function( id ) { var pending; id = id || this.get('view'); pending = this._pendingViews[ id ]; if ( ! this._views[ id ] && pending ) { this._views[ id ] = new pending.view( _.extend({ controller: this }, pending.options || {} ) ); delete this._pendingViews[ id ]; this.trigger( 'init init:' + id, this._views[ id ] ); } return this._views[ id ]; }, // Unregisters a view from the workflow. // // Triggers the `remove` and `remove:VIEW_ID` events. remove: function( id ) { delete this._views[ id ]; delete this._pendingViews[ id ]; this.trigger( 'remove remove:' + id ); return this; }, // Renders a view and places it within the modal window. // Automatically adds a view if `constructor` is provided. render: function( id, constructor, options ) { var view; id = id || this.get('view'); if ( constructor ) this.add( id, constructor, options ); view = this.view( id ); if ( ! view ) return this; view.render(); this.modal.content( view ); return this; }, update: function( event ) { this.close(); this.trigger( 'update', this.selection, this ); this.trigger( 'update:' + event, this.selection, this ); this.selection.clear(); }, createSelection: function() { var controller = this; // Initialize workflow-specific models. // Use the `selection` property to initialize the Attachments // collection, then unset the property. this.selection = new Attachments( this.get('selection') ); this.unset('selection'); _.extend( this.selection, { // Override the selection's add method. // If the workflow does not support multiple // selected attachments, reset the selection. add: function( models, options ) { if ( ! controller.get('multiple') ) { models = _.isArray( models ) ? _.first( models ) : models; this.clear( options ); } return Attachments.prototype.add.call( this, models, options ); }, // Removes all models from the selection. clear: function( options ) { return this.remove( this.models, options ); }, // Override the selection's reset method. // Always direct items through add and remove, // as we need them to fire. reset: function( models, options ) { return this.clear( options ).add( models, options ); }, // Create selection.has, which determines if a model // exists in the collection based on cid and id, // instead of direct comparison. has: function( attachment ) { return !! ( this.getByCid( attachment.cid ) || this.get( attachment.id ) ); } }); } }); // Map modal methods to the workflow. _.each(['attach','detach','open','close'], function( method ) { media.controller.Workflow.prototype[ method ] = function() { this.modal[ method ].apply( this.modal, arguments ); return this; }; }); /** * ======================================================================== * VIEWS * ======================================================================== */ /** * wp.media.view.Modal */ media.view.Modal = Backbone.View.extend({ tagName: 'div', template: media.template('media-modal'), events: { 'click .media-modal-backdrop, .media-modal-close' : 'closeHandler' }, initialize: function() { this.controller = this.options.controller; this.controller.on( 'change:title', this.render, this ); _.defaults( this.options, { container: document.body }); }, render: function() { // Ensure content div exists. this.options.$content = this.options.$content || $('
'); // Detach the content element from the DOM to prevent // `this.$el.html()` from garbage collecting its events. this.options.$content.detach(); this.$el.html( this.template( this.controller.toJSON() ) ); this.$('.media-modal-content').append( this.options.$content ); return this; }, attach: function() { this.$el.appendTo( this.options.container ); this.controller.trigger( 'attach', this.controller ); }, detach: function() { this.$el.detach(); this.controller.trigger( 'detach', this.controller ); }, open: function() { this.$el.show(); this.controller.trigger( 'open', this.controller ); }, close: function() { this.$el.hide(); this.controller.trigger( 'close', this.controller ); }, closeHandler: function( event ) { event.preventDefault(); this.close(); }, content: function( $content ) { // Detach any existing content to prevent events from being lost. if ( this.options.$content ) this.options.$content.detach(); // Set and render the content. this.options.$content = ( $content instanceof Backbone.View ) ? $content.$el : $content; return this.render(); } }); /** * wp.media.view.Toolbar */ media.view.Toolbar = Backbone.View.extend({ tagName: 'div', className: 'media-toolbar', initialize: function() { this._views = {}; this.$primary = $('').prependTo( this.$el ); this.$secondary = $('').prependTo( this.$el ); if ( this.options.items ) { _.each( this.options.items, function( view, id ) { this.add( id, view, { silent: true } ); }, this ); this.render(); } }, render: function() { var views = _.chain( this._views ).sortBy( function( view ) { return view.options.priority || 10; }).groupBy( function( view ) { return ( view.options.priority || 10 ) > 0 ? 'primary' : 'secondary'; }).value(); // Make sure to detach the elements we want to reuse. // Otherwise, `jQuery.html()` will unbind their events. $( _.pluck( this._views, 'el' ) ).detach(); this.$primary.html( _.pluck( views.primary || [], 'el' ) ); this.$secondary.html( _.pluck( views.secondary || [], 'el' ) ); return this; }, add: function( id, view, options ) { if ( ! ( view instanceof Backbone.View ) ) { view.classes = [ id ].concat( view.classes || [] ); view = new media.view.Button( view ).render(); } this._views[ id ] = view; if ( ! options || ! options.silent ) this.render(); return this; }, get: function( id ) { return this._views[ id ]; }, remove: function( id, options ) { delete this._views[ id ]; if ( ! options || ! options.silent ) this.render(); return this; } }); /** * wp.media.view.Button */ media.view.Button = Backbone.View.extend({ tagName: 'a', className: 'media-button', attributes: { href: '#' }, events: { 'click': 'click' }, defaults: { text: '', style: '', size: 'large' }, 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 ); if ( this.options.dropdown ) this.options.dropdown.addClass('dropdown'); this.model.on( 'change', this.render, this ); }, render: function() { var classes = [ 'button', this.className ]; if ( this.model.get('style') ) classes.push( 'button-' + this.model.get('style') ); if ( this.model.get('size') ) classes.push( 'button-' + this.model.get('size') ); classes = _.uniq( classes.concat( this.options.classes ) ); this.el.className = classes.join(' '); // Detach the dropdown. if ( this.options.dropdown ) this.options.dropdown.detach(); this.$el.text( this.model.get('text') ); if ( this.options.dropdown ) this.$el.append( this.options.dropdown ); return this; }, click: function( event ) { event.preventDefault(); if ( this.options.click ) this.options.click.apply( this, arguments ); } }); /** * wp.media.view.ButtonGroup */ media.view.ButtonGroup = Backbone.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.Attachment */ media.view.Attachment = Backbone.View.extend({ tagName: 'li', className: 'attachment', template: media.template('attachment'), events: { 'click .attachment-preview': 'toggleSelection', 'mouseenter .attachment-preview': 'shrink', 'mouseleave .attachment-preview': 'expand', 'change .describe': 'describe', 'click .close': 'toggleSelection' }, buttons: {}, initialize: function() { this.controller = this.options.controller; this.model.on( 'change:sizes change:uploading', this.render, this ); this.model.on( 'change:percent', this.progress, this ); this.model.on( 'add', this.select, this ); this.model.on( 'remove', this.deselect, this ); // Prevent default navigation on all links. this.$el.on( 'click', 'a', this.preventDefault ); }, render: function() { var attachment = this.model.toJSON(), options = _.defaults( this.model.toJSON(), { orientation: 'landscape', uploading: false, type: '', subtype: '', icon: '', filename: '', caption: '', title: '' }); options.buttons = this.buttons; options.describe = this.controller.get('describe'); if ( 'image' === options.type ) _.extend( options, this.crop() ); this.$el.html( this.template( options ) ); if ( options.uploading ) this.$bar = this.$('.media-progress-bar div'); else delete this.$bar; // Check if the model is selected. if ( this.controller.selection.has( this.model ) ) this.select(); return this; }, progress: function() { if ( this.$bar && this.$bar.length ) this.$bar.width( this.model.get('percent') + '%' ); }, toggleSelection: function( event ) { var selection = this.controller.selection; selection[ selection.has( this.model ) ? 'remove' : 'add' ]( this.model ); }, select: function( model, collection ) { // If a collection is provided, check if it's the selection. // If it's not, bail; we're in another selection's event loop. if ( collection && collection !== this.controller.selection ) return; this.$el.addClass('selected'); }, deselect: function( model, collection ) { // If a collection is provided, check if it's the selection. // If it's not, bail; we're in another selection's event loop. if ( collection && collection !== this.controller.selection ) return; this.$el.removeClass('selected'); }, 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 sizes[ size ]; } else { return { url: this.model.get('url'), width: this.model.get('width'), height: this.model.get('height'), orientation: this.model.get('orientation') }; } }, crop: function( sizeId ) { var edge = 199, size = this.imageSize( sizeId ), wide, tall; wide = wp.media.fit( _.extend( { maxWidth: edge }, size ) ); tall = wp.media.fit( _.extend( { maxHeight: edge }, size ) ); _.extend( size, wide.width > tall.width ? wide : tall ); size.top = ( edge - size.height ) / 2; size.left = ( edge - size.width ) / 2; return size; }, fit: function( sizeId ) { var margin = 10, full = 199, edge = full - ( margin * 2 ), size = _.extend( wp.media.fit( _.extend({ maxWidth: edge, maxHeight: edge }, this.imageSize( sizeId ) ) ) ); size.top = Math.round( margin + ( edge - size.height ) / 2 ); size.left = Math.round( margin + ( edge - size.width ) / 2 ); return size; }, shrink: function() { var size = _.pick( this.fit(), 'top', 'left', 'width', 'height' ); this.$el.addClass('fit'); this.$('.thumbnail').css( size ); this.$('.thumbnail img').css( _.extend( size, { top: 0, left: 0 } ) ); }, expand: function() { var size = _.pick( this.crop(), 'top', 'left', 'width', 'height' ); this.$el.removeClass('fit'); this.$('.thumbnail img').css( size ); this.$('.thumbnail').css({ top: 0, left: 0, width: 199, height: 199 }); }, describe: function( event ) { if ( 'image' === this.model.get('type') ) this.model.save( 'caption', event.target.value ); else this.model.save( 'title', event.target.value ); } }); /** * wp.media.view.Attachment.Library */ media.view.Attachment.Library = media.view.Attachment.extend({ className: 'attachment library' }); /** * wp.media.view.Attachment.Gallery */ media.view.Attachment.Gallery = media.view.Attachment.extend({ buttons: { close: true }, events: (function() { var events = _.clone( media.view.Attachment.prototype.events ); delete events['click .attachment-preview']; return events; }()) }); /** * wp.media.view.Workspace */ media.view.Workspace = Backbone.View.extend({ tagName: 'div', className: 'media-workspace', template: media.template('media-workspace'), // The `options` to be passed to `Attachments` view. attachmentsView: {}, events: { 'dragenter': 'maybeInitUploader', 'mouseenter': 'maybeInitUploader' }, initialize: function() { this.controller = this.options.controller; _.defaults( this.options, { selectOne: false, uploader: {}, attachmentsView: {} }); this.$content = $(''); // Generate the `options` passed to the `Attachments` view. // Order of priority from lowest to highest: the provided defaults, // the prototypal `attachmentsView` property, the `attachmentsView` // option for the current instance, and then the `controller` and // `collection` keys, to ensure they're correctly set. this.attachmentsView = _.extend( { directions: this.controller.get('multiple') ? l10n.selectMediaMultiple : l10n.selectMediaSingular }, this.attachmentsView, this.options.attachmentsView, { controller: this.controller, collection: this.collection }); // Initialize the `Attachments` view. this.attachmentsView = new media.view.Attachments( this.attachmentsView ); this.$content.append( this.attachmentsView.$el ); // Track uploading attachments. wp.Uploader.queue.on( 'add remove reset change:percent', this.renderUploadProgress, this ); // If we're in a workflow that supports multiple attachments, // automatically select any uploading attachments. if ( this.controller.get('multiple') ) wp.Uploader.queue.on( 'add', this.selectUpload, this ); }, render: function() { this.$content.detach(); this.attachmentsView.render(); this.renderUploadProgress(); this.$el.html( this.template( this.options ) ).append( this.$content ); this.$bar = this.$('.upload-attachments .media-progress-bar div'); return this; }, maybeInitUploader: function() { var workspace = this; // If the uploader already exists or the body isn't in the DOM, bail. if ( this.uploader || ! this.$el.closest('body').length ) return; this.uploader = new wp.Uploader( _.extend({ container: this.$el, dropzone: this.$el, browser: this.$('.upload-attachments a') }, this.options.uploader ) ); }, selectUpload: function( attachment ) { this.controller.selection.add( attachment ); }, renderUploadProgress: function() { var queue = wp.Uploader.queue; this.$el.toggleClass( 'uploading', !! queue.length ); if ( ! this.$bar || ! queue.length ) return; this.$bar.width( ( queue.reduce( function( memo, attachment ) { if ( attachment.get('uploading') ) return memo + ( attachment.get('percent') || 0 ); else return memo + 100; }, 0 ) / queue.length ) + '%' ); } }); /** * wp.media.view.Workspace.Library */ media.view.Workspace.Library = media.view.Workspace.extend({ attachmentsView: { // The single `Attachment` view to be used in the `Attachments` view. AttachmentView: media.view.Attachment.Library }, initialize: function() { media.view.Workspace.prototype.initialize.apply( this, arguments ); // If this supports multiple attachments, initialize the sample toolbar view. if ( this.controller.get('multiple') ) this.initToolbarView(); }, // Initializes the toolbar view. Currently uses defaults set for // inserting media into a post. This should be pulled out into the // appropriate workflow when the time comes, but is currently here // to test multiple selections. initToolbarView: function() { var controller = this.controller; this.toolbarView = new media.view.Toolbar({ items: { 'selection-preview': new media.view.SelectionPreview({ controller: this.controller, collection: this.controller.selection, priority: -40 }), 'create-new-gallery': { style: 'primary', text: l10n.createNewGallery, priority: 40, click: function() { controller.render('gallery'); } }, 'insert-into-post': new media.view.ButtonGroup({ priority: 30, classes: 'dropdown-flip-x', buttons: [ { text: l10n.insertIntoPost, click: _.bind( controller.update, controller, 'insert' ) }, { classes: ['down-arrow'], dropdown: new media.view.AttachmentDisplaySettings().render().$el, click: function( event ) { var $el = this.$el; if ( ! $( event.target ).closest('.dropdown').length ) $el.toggleClass('active'); // Stop the event from propagating further so we can bind // a one-time event to the body (and ensure that a click // on the dropdown won't trigger said event). event.stopPropagation(); if ( $el.is(':visible') ) { $(document.body).one( 'click', function() { $el.removeClass('active'); }); } } } ] }).render(), 'add-to-gallery': { text: l10n.addToGallery, priority: 20 } } }); this.controller.selection.on( 'add remove', function() { var count = this.controller.selection.length, showGallery; this.$el.toggleClass( 'with-toolbar', !! count ); // Check if every attachment in the selection is an image. showGallery = count > 1 && this.controller.selection.all( function( attachment ) { return 'image' === attachment.get('type'); }); this.toolbarView.get('create-new-gallery').$el.toggle( showGallery ); insert = this.toolbarView.get('insert-into-post'); _.each( insert.buttons, function( button ) { button.model.set( 'style', showGallery ? '' : 'primary' ); }); }, this ); this.$content.append( this.toolbarView.$el ); } }); media.view.Workspace.Library.Gallery = media.view.Workspace.Library.extend({ initToolbarView: function() { var controller = this.controller, editing = controller.get('editing'), items = { 'selection-preview': new media.view.SelectionPreview({ controller: this.controller, collection: this.controller.selection, priority: -40, clearable: false }), 'continue-editing-gallery': { style: 'primary', text: l10n.continueEditingGallery, priority: 40, click: function() { controller.render( 'gallery' ); } } }; this.toolbarView = new media.view.Toolbar({ items: items }); this.$el.addClass('with-toolbar'); this.$content.append( this.toolbarView.$el ); } }); /** * wp.media.view.Workspace.Gallery */ media.view.Workspace.Gallery = media.view.Workspace.extend({ attachmentsView: { // The single `Attachment` view to be used in the `Attachments` view. AttachmentView: media.view.Attachment.Gallery, sortable: true }, initialize: function() { media.view.Workspace.prototype.initialize.apply( this, arguments ); this.initToolbarView(); }, // Initializes the toolbar view. Currently uses defaults set for // inserting media into a post. This should be pulled out into the // appropriate workflow when the time comes, but is currently here // to test multiple selections. initToolbarView: function() { var controller = this.controller, editing = controller.get('editing'), items = { 'update-gallery': { style: 'primary', text: editing ? l10n.updateGallery : l10n.insertGalleryIntoPost, priority: 40, click: _.bind( controller.update, controller, 'gallery' ) }, 'return-to-library': { text: editing ? l10n.addImagesFromLibrary : l10n.returnToLibrary, priority: -40, click: function() { controller.render( editing ? 'gallery-library' : 'library' ); } } }; this.toolbarView = new media.view.Toolbar({ items: items }); this.$el.addClass('with-toolbar'); this.$content.append( this.toolbarView.$el ); } }); /** * wp.media.view.Attachments */ media.view.Attachments = Backbone.View.extend({ tagName: 'div', className: 'attachments', template: media.template('attachments'), events: { 'keyup .search': 'search' }, initialize: function() { this.controller = this.options.controller; _.defaults( this.options, { refreshSensitivity: 200, refreshThreshold: 3, AttachmentView: media.view.Attachment, sortable: false }); _.each(['add','remove'], function( method ) { this.collection.on( method, function( attachment, attachments, options ) { this[ method ]( attachment, options.index ); }, this ); }, this ); this.collection.on( 'reset', this.refresh, this ); this.$list = $('