app/assets/javascripts/slices/app/views/attachment_composer_view.js
// Responsible for managing the ui for a collection of attachments. Works in
// conjunction with `AttachmentView`, `Attachment`, `AttachmentCollection`
// and specialized Handlebars helper `attachmentComposer`.
// A JSON description of the collection is written to the element’s
// data-computed-value, and subsequently read by Slices when saving the Page.
//
// This shouldn’t be instantiated directly.
// Instead, use `{{attachmentComposer}}` like this:
//
// {{#attachmentComposer myAttachments}}
// <textarea name="caption">{{caption}}</textarea>
// {{/attachmentComposer}}
//
slices.AttachmentComposerView = Backbone.View.extend({
DROP_THRESHOLD: 15,
views: {}, // internal view cache
events: {
'click [data-action="library"]' : 'openAssetDrawer',
'click [data-action="remove"]' : 'removeClicked'
},
template: Handlebars.compile(
'<ol class="attachment-list"></ol>' +
'<div class="attachment-actions">' +
'<button data-action="library">Choose from Library</button>' +
'<button data-action="upload">Upload from computer</button>' +
'</div>'
),
className: 'attachment-composer',
broadcastChanges: true,
// Initialize the view. There are a few steps here, so read on.
initialize: function() {
_.bindAll(this);
// If this.options.collection is just a simple array, we need to
// instantiate and AttachmentCollection.
this.collection = new slices.AttachmentCollection(this.options.collection);
this.collection.bind('add' , this.addAttachment);
this.collection.bind('remove' , this.removeAttachment);
this.collection.bind('change' , this.update);
this.collection.bind('reset' , this.update);
// Listen out for asset drags and drops.
$(window).on('assets:dragStarted', this.onAssetDragStarted);
if (this.options.autoAttach) {
// Defer the attachment of the real view element.
_.defer(this.attach);
}
},
// Placeholder element to render into later.
placeholder: function() {
return Handlebars.compile('<div id="placeholder-{{id}}"></div>')(this);
},
render: function() {
this.broadcastChanges = false;
$(this.el).html(this.template(this));
this.collection.each(this.addAttachment);
this.makeSortable();
this.makeUploader();
this.update();
this.broadcastChanges = true;
return this;
},
// Replace our placeholder element with this.el.
attach: function() {
$('#placeholder-' + this.id).replaceWith(this.el);
this.render();
},
// Add ui and references for an attachment.
addAttachment: function(attachment, collection, options) {
var view = new slices.AttachmentView({
fields: this.options.fields,
model: attachment
});
if (options.index < collection.length - 1) {
view.$el.insertBefore(this.$('.attachment-list').children()[options.index]);
} else {
this.$('.attachment-list').append(view.el);
}
view.render();
this.views[attachment.cid] = view;
this.update();
},
// Remove ui and references for an attachment.
removeAttachment: function(attachment) {
var view = this.views[attachment.cid];
view.remove();
delete this.views[attachment.cid];
if (attachment.file) this.uploader.removeFile(attachment.file);
this.update();
},
// Write a JSON representation of the collection into data-computed-value
// on this.el. Slices picks up on the computed-value when saving the page.
// Ignores any items with a null asset_id, which are likely to be
// failed uploads.
update: function() {
var value = this.collection.toJSON(),
$el = $(this.el);
value = _.reject(value, function(a) { return a.asset_id == null });
$el.data('computed-value', value);
$el[this.collection.isEmpty() ? 'removeClass' : 'addClass']('not-empty');
if (this.broadcastChanges) $el.trigger('change');
},
// Infers a view and asset from just-clicked button and attempts to remove
// the asset from the collection. Will prevent action if upload is in
// progress - not ideal, but Plupload doesn't support cancelling an
// in-progress uploader.
removeClicked: function(e) {
var button = $(e.target),
view = button.parent('li'),
attachment = view.data('model');
this.collection.remove(attachment);
},
// Shows the asset library.
openAssetDrawer: function(e) {
e.preventDefault();
e.stopImmediatePropagation();
slices.assetDrawer().open({ step: this.assetDrawerStep });
},
assetDrawerStep: function() {
var el = $(this.el),
bottom = el.offset().top + el.outerHeight() + 30,
drawerTop = $(slices.assetDrawer().el).offset().top;
if (bottom > drawerTop) {
var body = $('body');
body.scrollTop(body.scrollTop() + (bottom - drawerTop));
}
},
// Returns the Attachment view responsible for given File.
viewForFile: function(file) {
return this.views[file.attachment.cid];
},
// Make items sortable using jQuery UI sortable plugin.
makeSortable: function() {
this.$('.attachment-list').sortable({
handle: '.attachment-thumb',
scroll: false,
beforeStart: _.bind(function(e, ui) {
this.attachmentList().freezeHeight();
window.autoscroll.start();
}, this),
stop: _.bind(function(e, ui) {
this.attachmentList().thawHeight();
window.autoscroll.stop();
}, this),
update: this.updateOnSort
});
},
// Update collection to match the visible order of item elements.
// We avoid jQuery.map here, because it returns some sort of weird
// jquery object, rather than an array.
updateOnSort: function() {
var newOrder = _.map(this.$('.attachment-list li').get(), function(li) {
return $(li).data('model');
});
this.collection.reset(newOrder);
},
// Create an uploader instance and bind up its callbacks.
makeUploader: function() {
this.uploader = new slices.Uploader({
button : this.$('[data-action="upload"]'),
drop : this.el
});
this.uploader.bind('filesAdded', this.onFilesAdded);
this.uploader.bind('fileUploaded', this.onFileUploaded);
},
// When files are added to the upload queue we create corresponding
// attachment objects and add them to the collection.
onFilesAdded: function(event) {
var files = event.files,
uploader = event.uploader;
_(files).each(function(file) {
var a = new slices.Attachment({
asset: new slices.Asset({ file: file })
});
// This is clearly a code-smell, but it lets us easily look-up
// the attachment-view for this file when events occur.
file.attachment = a;
// These bits are fine.
this.collection.add(a);
this.updateFileStatus(file);
}, this);
this.uploader.start();
},
// This looks weird, I know, but really all we’re doing is taking the
// response from our upload to /assets and feeding it into our
// attachment model.
onFileUploaded: function(event) {
var file = event.file,
response = event.response,
attachment = file.attachment,
asset = attachment.get('asset');
// Update the attachment model with just the new asset_id and update
// the underlying asset with all the new info. This could do with
// refactoring to make it better reveal its intent.
attachment.set({ asset_id: response.id });
asset.set(response);
// Finally complete upload progress display and transition to thumbnail.
this.viewForFile(file).updateFileAndComplete(file);
// Need to update when uploads complete, so data('value') correctly
// reflects all these attachments.
this.update();
// Send the signal to any asset library views.
$(window).trigger('assets:uploadCompleted');
},
// Tell the appropriate attachment object to update against the file.
// This needs to be deferred, for reasons mentioned above.
updateFileStatus: function(file) {
this.viewForFile(file).updateFile(file);
},
// The following methods implement the asset-receiver interface.
// See slices.AssetLibraryView for details.
onAssetDragStarted: function(e, library) {
library.registerReceiver(this);
},
withinBounds: function(x, y) {
var offset = $(this.el).offset(),
top = offset.top - this.DROP_THRESHOLD,
left = offset.left - this.DROP_THRESHOLD,
bottom = top + $(this.el).height() + (this.DROP_THRESHOLD * 2),
right = left + $(this.el).width() + (this.DROP_THRESHOLD * 2);
return x >= left && x <= right && y >= top && y <= bottom;
},
cursor: function() {
return this._cursor = this._cursor || $('<li class="cursor">');
},
assetsOver: function(x, y) {
this.$el.addClass('assets-over');
// So as not to cause excessive re-flows, we only want to move the cursor
// when the placement point actually changes.
//
// The following steps aren’t pretty, and could do with a refactor.
//
// Find the new placement point.
var p = this.findPlacementPoint(x, y);
// If we’ve a placement point in memory, and a new point was found,
// and they share the same index, then we won’t be moving.
var same = (this.placementPoint && p && this.placementPoint.index === p.index);
// Commit the placement point to memory.
this.placementPoint = p;
// If the point hasn’t changed, duck out at this point.
if (same) return;
// If a placement point was found, insert the cursor before.
if (p) {
this.cursor().insertBefore(p.view.el);
// Otherwise, just append to attachment-list.
} else {
this.cursor().appendTo(this.$('.attachment-list'));
}
},
assetsNotOver: function() {
this.$el.removeClass('assets-over');
this.cursor().detach();
delete this.placementPoint;
},
receiveAssets: function(assets, x, y) {
var p = this.findPlacementPoint(x, y),
position = p ? p.index : this.collection.length;
_.each(assets, function(asset) {
if (!this.options.allowDupes && this.alreadyContains(asset)) return;
this.collection.add({ asset: asset, asset_id: asset.get('id') }, { at: position });
position += 1;
}, this);
},
// Returns the point at which an asset drop should be placed for the
// given coordinates, as a Hash containing the following model, following
// view and exact index for placement. If no suitable point is found, it
// returns null.
//
// findPlacementPoint(0, 0) //-> { attachment: a, view: v, :index: i }
//
findPlacementPoint: function(x, y) {
var result = null,
views = this.views;
this.collection.find(function(a, i) {
var v = views[a.cid];
if (v.midPoint().y > y) {
result = { attachment: a, view: v, index: i };
return true;
}
return false;
});
return result;
},
// Returns true if the given asset is already in our collection.
alreadyContains: function(asset) {
var id = asset.get('id');
return this.collection.any(function(attachment) {
return attachment.get('asset_id') === id;
});
},
attachmentList: function() {
return this.$('.attachment-list');
}
});