// The asset library is dedicated to browsing, searching and managing of
// assets. It can be rendered in-place, or to a specific element using the
// helper method.
//
// To render the library inline in html:
//
//
//
// Otherwise:
//
// slices.renderAssetLibrary({ el: '#my-target' })
//
slices.AssetLibraryView = Backbone.View.extend({
DRAG_THRESHOLD: 15,
DRAWER_HEIGHT: 250,
events: {
'keyup [type="search"]' : 'search',
'click [type="search"]' : 'search',
'click [data-action="close"]' : 'close',
'click [data-action="show-all"]' : 'showAll',
'mousedown .library-container' : 'backgroundPress',
'mousedown .resize-handle' : 'startResize'
},
thumbs: {},
selection: [],
currentSearchTerm: '',
className: 'asset-library',
// We include the full template here, so the view can be rendered anywhere
// without html needing to be present ahead of time.
template: Handlebars.compile(
'
' +
''
),
// The collection is instantiated during initialization, as this is an
// 'AppView' style class.
initialize: function() {
_.bindAll(this);
this.collection = new slices.AssetCollection();
this.collection.bind('add', this.add);
this.collection.bind('remove', this.remove);
this.collection.bind('reset', this.reset);
$(window).on('assets:uploadCompleted', this.fetch);
this.render();
this.scrollArea = this.$('.library-container');
this.scrollArea.on('scroll', this.onScroll);
this.initializeUploader();
this.showLoadingSpinner();
this.fetch();
},
render: function() {
$(this.el).html(this.template(this));
return this;
},
// Clear all thumbs on display and re-render the collection.
reset: function() {
this.clearThumbs();
this.collection.each(this.add);
},
// Create a thumb view for the model, render it, and stick it in
// our reference hash.
add: function(model) {
var thumb = new slices.AssetThumbView({ model: model, selectable: true });
thumb.bind('thumb:press', this.thumbPress);
thumb.bind('thumb:release', this.thumbRelease);
thumb.bind('thumb:blur', this.thumbBlur);
this.loadingSpinner().before(thumb.render().el);
this.thumbs[model.cid] = thumb;
this.updateCount();
},
// Ask the thumb view to remove itself, and delete the reference.
remove: function(model) {
this.clearThumb(model.cid);
this.updateCount();
},
// Handle special keystrokes, or defer to our debounced general search.
search: function(e) {
if (e.which == 27) this.$('[type="search"]').val('');
this._search(e);
},
// This debounced method actually performs the search on our collection.
_search: _.debounce(function(e) {
var term = this.searchTerm();
if (term === this.currentSearchTerm) return;
this.currentSearchTerm = term;
this.fetch();
}, 300),
// Assuming we’re in upload-view, take us back to normal.
showAll: function() {
if (this.uploader.files.length > 0) {
alert('Uploads are still in progress, please wait for them to finish.');
} else {
this.fetch();
}
},
// Remove all thumb views currently on display.
clearThumbs: function() {
this.updateCount('Searching…');
for (var cid in this.thumbs) this.clearThumb(cid);
},
// Remove a specific thumb.
clearThumb: function(cid) {
var thumb = this.thumbs[cid];
this.selection = _(this.selection).without(thumb);
thumb.remove();
delete this.thumbs[cid];
},
// Helper method for getting the value of the search field.
searchTerm: function() {
return this.$('[type="search"]').val();
},
// If we’re in drawer mode, we’ll display a hint that users should
// drag and drop.
hint: function() {
if (this.options.mode === 'drawer') {
return 'Drag & drop to place on the page
';
}
},
// If we’re in drawer mode, we’ll add a resize handle in here.
resizeHandle: function() {
if (this.options.mode === 'drawer') {
return '';
}
},
// We render different action button depending on the mode.
actions: function() {
var actions = [];
actions.push('Upload');
if (this.options.mode === 'drawer') {
actions.push('Close');
}
return new Handlebars.SafeString(actions.join(''));
},
// The open and close methods only apply if in 'drawer' mode.
open: function(options) {
$('#container').stop().animate({ paddingBottom: this.DRAWER_HEIGHT + 'px' });
$(this.el).stop().animate(
{ height: this.DRAWER_HEIGHT + 'px' },
_.extend({}, options, { complete: this.afterOpen })
);
},
afterOpen: function() {
this.$('input[type="search"]')[0].focus();
},
close: function() {
this.$('input[type="search"]')[0].blur();
$('#container').stop().animate({ paddingBottom: '0px' });
$(this.el).stop().animate({ height: '0px' });
},
// Thumb press/release behaviour mimicks that of operating system file
// selection. Holding down shift selects ranges of files, with memory of
// the anchor. Holding down cmd/ctrl selects multiple assets. Clicks
// without modifiers select single assets.
//
// This method is disgraceful at the moment, because there are a few
// modes of behaviour. It’s an ideal candidate for refactoring.
thumbPress: function(event, thumb) {
_.invoke(this.selection, 'deselect');
if (event.shiftKey) {
if (this.selection.length === 0) {
this.selection.push(thumb);
this.selectionAnchor = thumb;
} else {
var first = this.selectionAnchor,
start = this.collection.indexOf(first.model),
end = this.collection.indexOf(thumb.model),
lowest = Math.min(start, end),
highest = Math.max(start, end);
this.selection = [];
for (var i = lowest; i <= highest; i++) {
var thumb = this.thumbs[this.collection.at(i).cid];
this.selection.push(thumb);
}
}
} else if (event.metaKey) {
if (_(this.selection).include(thumb)) {
this.selection = _(this.selection).without(thumb);
} else {
this.selection.push(thumb);
}
this.selectionAnchor = _.sortBy(this.selection, function(thumb) {
return thumb.model.collection.indexOf(thumb.model);
})[0];
} else {
if (!_(this.selection).include(thumb)) {
this.selection = [thumb];
this.selectionAnchor = thumb;
}
}
_.invoke(this.selection, 'select');
if (this.selection.length > 0) this.prepareForDrag(event);
this.listenForKeys();
},
// Most selection releated behaviours happen on mousedown. The exception
// is when multiple assets are selected - a single unmodified click will
// set the selection to the clicked asset, but only when the mouse is
// released.
thumbRelease: function(event, thumb) {
if (event.metaKey || event.shiftKey) return;
_.invoke(this.selection, 'deselect');
this.selection = [thumb];
this.selectionAnchor = thumb;
_.invoke(this.selection, 'select');
},
// Thumb Blur is not the best name, but this basically implies that an
// action has taken place which should cause asset library thumb-related
// actions to take a back-seat. This is used when the asset editor is
// opened.
thumbBlur: function() {
this.deselectAll();
},
// When the background is pressed, the selection is cleared out.
backgroundPress: function(event) {
if (event.shiftKey || event.metaKey) return;
event.preventDefault();
event.stopImmediatePropagation();
this.deselectAll();
},
// Deselect all thumbs and trigger appropriate actions.
deselectAll: function() {
_.invoke(this.selection, 'deselect');
this.selection = [];
},
// Array of models from the current selection.
selectedAssets: function() {
return _.pluck(this.selection, 'model');
},
// Update the UI count of asset(s) found.
updateCount: function(message) {
this.$('.count').html(message || this.countMessage());
},
// Messages for different asset counts.
countMessages: {
default: {
none: Handlebars.compile('No assets, yet…'),
one: Handlebars.compile('Showing 1 asset'),
some: Handlebars.compile(
'Showing latest {{count}} assets of {{total}} total'
),
all: Handlebars.compile(
'Showing all {{count}} assets, latest first'
)
},
uploads: {
one: Handlebars.compile(
'Showing 1 new asset ' +
'(Show everything)'
),
some: Handlebars.compile(
'Showing {{count}} new assets ' +
'(Show everything)'
),
},
search: {
none: Handlebars.compile('No matching assets'),
one: Handlebars.compile('Showing 1 matching asset'),
some: Handlebars.compile(
'Showing latest {{count}} matching assets of {{total}} total'
),
all: Handlebars.compile(
'Showing all {{count}} maching assets, latest first'
)
}
},
// Returns the appropriate message count for the current context.
countMessage: function() {
var t = this.countMessages,
total = this.collection.totalEntries,
count = this.collection.length;
if (this.inUploadView) {
t = t.uploads;
if (count === 1) {
return t.one({ count: count });
} else {
return t.some({ count: count });
}
} else {
t = this.currentSearchTerm ? t.search : t.default;
if (total === 0) {
return t.none();
} else if (total === 1) {
return t.one();
} else if (total > count) {
return t.some({ total: total, count: count });
} else {
return t.all({ total: total, count: count });
}
}
},
NEXT_PAGE_THRESHOLD: 600,
// Potentially load more entries on scroll.
onScroll: _.debounce(function(e) {
if (this.loading) return;
if (this.inUploadView) return;
var library = this.$('.library'),
scrollTop = this.scrollArea.scrollTop(),
scrollBottom = scrollTop + this.scrollArea.height(),
remaining = library.height() - scrollBottom;
if (remaining <= this.NEXT_PAGE_THRESHOLD && this.collection.hasMorePages()) {
this.showLoadingSpinner();
this.fetch({ add: true });
}
}, 300),
// Spinner graphic, revealed as necessary.
loadingSpinner: _.memoize(function() {
return $(
'' +
'Loading…' +
''
).appendTo(this.$('.library'));
}),
showLoadingSpinner: function() {
this.loadingSpinner().css({ display: 'block' });
},
hideLoadingSpinner: function() {
this.loadingSpinner().css({ display: 'none' });
},
// Wrapper around the collection’s fetch method. Takes seach
// and paging into account.
fetch: function(options) {
options = _.extend({ add: false }, options);
this.loading = true;
this.exitUploadView();
this.showLoadingSpinner();
if (!options.add) this.collection.reset();
this.collection.fetch({
data: {
search: this.searchTerm(),
page: options.add ? (this.collection.currentPage + 1) : 1
},
add: options.add,
success: this.onFetchSuccess
});
},
// Hide spinner and set loading state back to false.
onFetchSuccess: function() {
this.loading = false;
this.hideLoadingSpinner();
this.updateCount();
},
// ## Drag-Drop API:
//
// Any object can hook into AssetLibrary’s drag-drop API by
// implementing the following protocol:
//
// Bind to the `assets:dragStarted` event and register to receive assets:
//
// var self = this;
//
// $(window).on('assets:dragStarted', function(event, library) {
// library.registerReceiver(self);
// });
//
// Implement the `withinBounds` method:
//
// this.withinBounds = function(x, y) {
// // Return true or false
// }
//
// Implement the `assetsOver` method:
//
// this.assetsOver = function(x, y) { ... }
//
// Implement the `assetsNotOver` method:
//
// this.assetsNotOver = function() { ... }
//
// Implement the `receiveAssets` method:
//
// this.receiveAssets = function(assets, x, y) { ... }
//
// Store the originating click, and bind onto relevant mouse events.
prepareForDrag: function(e) {
$(document).on('mousemove', this.onDrag).
on('mouseup', this.stopDrag);
this.dragOrigin = { x: e.pageX, y: e.pageY };
},
// If we’ve already started dragging, update the little helper so it follows
// the user’s mouse pointer around. Otherwise, test if we’ve crossed the
// threshold yet.
onDrag: function(e) {
if (this.dragging) {
this.dragHelper.css({ left: e.pageX + 'px', top: e.pageY + 'px' });
this.reviewReceiversForDrag(e.pageX, e.pageY);
} else {
this.testDragTreshold(e);
}
},
// Dragging only begins when the mouse is dragged over DRAG_TRESHOLD.
testDragTreshold: function(e) {
var absDelta = Math.max(
Math.abs(e.pageX - this.dragOrigin.x),
Math.abs(e.pageY - this.dragOrigin.y)
);
if (absDelta >= this.DRAG_THRESHOLD) this.startDrag(e);
},
// Set the dragging state to true and create the drag helper -
// the collection of little thumbs representing the current selection.
startDrag: function(e) {
this.dragging = true;
this.dragHelper = $('');
this.dragHelper.css({ position: 'absolute' });
for (var i in this.selection) { var thumb = this.selection[i];
var img = thumb.$('img').clone(false);
this.dragHelper.append(img);
}
this.dragHelper.appendTo('body');
var width = Math.ceil(Math.sqrt(this.selection.length)) *
(this.dragHelper.width() / this.selection.length);
this.dragHelper.width(width);
this.receivers = [this];
$(window).trigger('assets:dragStarted', this);
window.autoscroll.start();
this.onDrag(e);
},
// Unbind mousey events, delete the drag helper, and inform potenital
// recipients that a drop is taking place.
stopDrag: function(e) {
$(document).off('mousemove', this.onDrag).
off('mouseup', this.stopDrag);
window.autoscroll.stop();
if (this.dragHelper) {
this.dragHelper.remove();
delete this.dragHelper;
}
delete this.dragging;
this.reviewReceiversForDrop(e.pageX, e.pageY);
delete this.receivers;
},
// Registers a potential asset receiver. Receivers are polled in order.
registerReceiver: function(receiver) {
this.receivers.push(receiver);
},
// On drag, we’ll want to send `assetsNotOver` to all receivers,
// then identify the receiver under the cursor (if any) and it
// `assetsOver`.
reviewReceiversForDrag: function(x, y) {
var receiver = this.identifyReceiver(x, y);
_.each(this.receivers, function(r) {
if (r !== receiver) r.assetsNotOver();
else r.assetsOver(x, y);
});
},
// On drop, we’ll identify the receiver under the cursor and send
// `receiveAssets`, with the current selection.
reviewReceiversForDrop: function(x, y) {
_.invoke(this.receivers, 'assetsNotOver');
var receiver = this.identifyReceiver(x, y);
if (receiver) receiver.receiveAssets(this.selectedAssets(), x, y);
},
// Find the first receiver in the stack for which `withinBounds`
// returns true.
identifyReceiver: function(x, y) {
return _.find(this.receivers, function(receiver) {
return receiver.withinBounds(x, y);
});
},
// Internal implementation of asset-receiver interface.
//
// The library itself is first on the stack of receivers.
// So, if an asset payload is over the library,
// it will get be the identified receiver.
withinBounds: function(x, y) {
var offset = $(this.el).offset(),
top = offset.top,
left = offset.left,
bottom = top + $(this.el).height(),
right = left + $(this.el).width();
return x >= left && x <= right && y >= top && y <= bottom;
},
assetsOver: function() {},
assetsNotOver: function() {},
receiveAssets: function() {},
keydown: function(e) {
if (this.selection.length > 0 && e.which === 8) {
e.preventDefault();
e.stopImmediatePropagation();
this.destroySelection();
}
},
// Destroy the selected assets. This sends 'destroy' to each asset
// indvidually. If the responses to the DELETE requests are a bit slow,
// then this can look a little inelegant.
destroySelection: function() {
if (this.selection.length === 1) {
var message = 'Are you sure you want to delete this asset?';
} else {
var message = Handlebars.compile(
'Are you sure you want to delete these {{count}} assets?'
)({ count: this.selection.length });
}
if (confirm(message)) {
_.invoke(this.selectedAssets(), 'destroy');
}
},
// When this mode is switched on, AssetLibrary will listen out globally
// for keydown events; primarily to catch the delete key at the moment.
//
// If a click is encountered outside the area of the library, we jump
// out of this mode.
listenForKeys: function() {
if (this.listeningForKeys) return;
this.listeningForKeys = true;
$(window).on('keydown', this.keydown);
$(window).one('mousedown', this.stopListeningForKeys);
},
// Jump out of keydown-aware mode.
stopListeningForKeys: function() {
this.listeningForKeys = false;
$(window).off('keydown', this.keydown);
},
startResize: function(e) {
e.preventDefault();
e.stopImmediatePropagation();
this.resizeOrigin = { y: e.pageY, h: this.$el.height() };
$(window).on('mousemove', this.performResize).
on('mouseup', this.endResize);
},
performResize: function(e) {
var delta = e.pageY - this.resizeOrigin.y,
height = Math.max(this.resizeOrigin.h - delta, this.DRAWER_HEIGHT);
this.$el.height(height);
},
endResize: function(e) {
$('#container').css({ paddingBottom: this.$el.height() + 'px' });
$(window).off('mousemove', this.performResize).
off('mouseup', this.endResize);
},
// Enables upload-to-library functionality.
initializeUploader: function() {
if (this.uploader) return;
this.uploader = new slices.Uploader({
button : this.$el.find('[data-action="upload"]'),
drop : this.$el.find('.library-container')
});
this.uploader.on('filesAdded', this.onFilesAdded);
this.uploader.on('fileUploaded', this.onFileUploaded);
},
// When files are added to the upload queue we create corresponding
// asset objects and add them to the collection.
onFilesAdded: function(event) {
this.enterUploadView();
_(event.files).each(function(file) {
var a = new slices.Asset({ file: file });
// This is clearly a code-smell!
file.asset = 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
// asset model.
//onFileUploaded: function(uploader, file, transport) {
onFileUploaded: function(event) {
var file = event.file,
response = event.response;
// Update the attachment model. Silently, because we want to control
// how it redraws.
var asset = file.asset;
asset.set(response);
// Finally complete upload progress display and transition to thumbnail.
this.viewForFile(file).updateFileAndComplete(file);
},
// When uploading files we just display the new uploads. All this does is
// clear the current collection. There's also a catch in there to ensure
// we don’t accidentally clear the collection when we don’t mean to.
enterUploadView: function() {
if (this.inUploadView) return;
this.inUploadView = true;
this.collection.reset();
this.$('[type="search"]').hide();
this.updateCount('Uploading…');
},
// When we’re no longer concerned with just our uploaded files, we return
// to normal.
exitUploadView: function() {
if (!this.inUploadView) return;
this.inUploadView = false;
this.$('[type="search"]').show();
this.updateCount();
},
// Passes information from upload on to the file’s view.
updateFileStatus: function(file) {
this.viewForFile(file).updateFile(file);
},
// Returns the view object associated with the given file.
viewForFile: function(file) {
return this.thumbs[file.asset.cid];
}
});