Recently I faced a common task: upload user files (photos) with some data (newly created record) attached. So I needed an ajax uploader with following features:
- Uploading few files in one request
- Sending data together with files
- Complete and error events that will be triggered just once for the whole request, not for every single file
- Preview for images
- Client-side validation
- Send data even without files
- Customizable template
First, I googled for existing solutions. Tried to use some addons, than tried to wrap one of existing uploaders into a component. But I didn’t found any that fits my needs. Some of them are not free for commercial use, some are unable to load all files with one request, some doesn’t provide a complete event for all files. After a day of struggle I decided to create my own component for file uploads and now I want to share my experience. Uploader will work in all modern browsers, including IE10+. IE9 is not supported, as it is incompatible with HTML5 File API.
You can get an app with implemented uploader on github. Below you can find a brief explanation.
Creating a minimal component
If you don’t know what is an ember component and how to create one, you may learn from here, here and here. Now, let ember generate a new component:
ember generate component x-uploader
In component’s template add a div that will serve as “add files” button:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
For styling I prefer SASS. In order to use it, we need to install an addon:
ember install ember-cli-sass
Let’s add a few rules:
/*../app/styles/app.scss*/ @import 'x-uploader';
/*../app/styles/_x-uploader.scss*/ .x-uploader { background: #eeeeee; padding: 4px; margin: 1rem 0; input[type=file] { display: none; } .add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; } }
Here is a code of a component:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader() { /** * In this array files will be stored * * @type {Array} * @private */ const _files = []; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { qf = new QueuedFile(files[i], true); _files.push(qf); filesAdded++; } if (filesAdded > 0) { console.log(_files); return true; } return false; }; /** * This method will upload files */ this.upload = function() { }; } export default Ember.Component.extend({ classNames: ['x-uploader'], name: "Files[]", acceptedTypes: '*/*,*', addLabel: 'Click here to add files', onInit: null, _input: null, didInsertElement() { /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} * @private */ this._uploader = new MultipleUploader(); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { add() { this._input.click(); } } });
At the top I defined two classes, QueuedFile and MultipleUploader. The first one will be used as a wrapper for File, and the second will store files and methods to manipulate them. Controller will have access to MultipleUploader and possibility to initiate an upload process by calling upload method. In didInsertElement hook we create an instance of MultipleUploader and generate a file input (hidden via css).
Here is a result:
It is now possible to add files and see them in console.
Displaying a list of files
To display a list of files, we need to introduce a queue property in component, sync it with a list of files and render using {{#each}} block. Queue will be an instance of Ember.ArrayProxy. To sync it with file list we will add small event system to MultipleUploader.
Updated component code:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader() { /** * In this array files are stored * * @type {Array} * @private */ const _files = []; /** * This object stores event handlers * * @type {{}} * @private */ const _eventHandlers = {}; /** * This function triggers an event * * @param event */ const trigger = function (event) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { _eventHandlers[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { qf = new QueuedFile(files[i], true); _files.push(qf); filesAdded++; trigger('fileadded', qf); } if (filesAdded > 0) { trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will upload files */ this.upload = function () { }; /** * This method attaches event handler * * @param event * @param handler */ this.on = function (event, handler) { if (_eventHandlers[event] === undefined) { _eventHandlers[event] = []; } _eventHandlers[event].push(handler); }; /** * This method detaches event handler * * @param event * @param handler */ this.off = function (event, handler) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { if (_eventHandlers[event][i] === handler) { _eventHandlers[event].splice(i, 1); break; } } } }; } export default Ember.Component.extend({ classNames: ['x-uploader'], /** * Component's parameters */ name: "Files[]", acceptedTypes: '*/*,*', addLabel: 'Click here to add files', /** * Queue */ queue: null, onInit: null, _input: null, init() { this._super.apply(this, arguments); this.set('queue', Ember.ArrayProxy.create({content: []})); }, didInsertElement() { /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} * @private */ this._uploader = new MultipleUploader(); var that = this; /** * Attach 'fileadded' event handler */ this._uploader.on('fileadded', function (qf) { that.get('queue').addObject(qf); }); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { add() { this._input.click(); } } });
Template:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="items"> {{#each queue as |QueuedFile index|}} <div class="item"> <div class="inner"> {{QueuedFile.file.name}} </div> </div> {{/each}} </div> <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
Styles:
/*../app/styles/_x-uploader.scss*/ .x-uploader { background: #eeeeee; padding: 4px; margin: 1rem 0; input[type=file] { display: none; } .items { .item { padding: 3px; .inner { background: #ffffff; padding: 8px; } } } .add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; } }
Modified app looks like this:
Removing files from upload queue
In order to remove file, we should remove file fromMultipleUploader‘s list of files and sync queue property of component with it.
Component:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader() { /** * In this array files are stored * * @type {Array} * @private */ const _files = []; /** * This object stores event handlers * * @type {{}} * @private */ const _eventHandlers = {}; /** * This function triggers an event * * @param event */ const trigger = function (event) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { _eventHandlers[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { qf = new QueuedFile(files[i], true); _files.push(qf); filesAdded++; trigger('fileadded', qf); } if (filesAdded > 0) { trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will remove file * * @param qf * @returns {boolean} */ this.removeFile = function (qf) { var pos; if ((pos = _files.indexOf(qf)) > -1) { _files.splice(pos, 1); trigger('fileremoved', qf); trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will upload files */ this.upload = function () { }; /** * This method attaches event handler * * @param event * @param handler */ this.on = function (event, handler) { if (_eventHandlers[event] === undefined) { _eventHandlers[event] = []; } _eventHandlers[event].push(handler); }; /** * This method detaches event handler * * @param event * @param handler */ this.off = function (event, handler) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { if (_eventHandlers[event][i] === handler) { _eventHandlers[event].splice(i, 1); break; } } } }; } export default Ember.Component.extend({ classNames: ['x-uploader'], /** * Component's parameters */ name: "Files[]", acceptedTypes: '*/*,*', addLabel: 'Click here to add files', /** * Queue */ queue: null, onInit: null, _input: null, init() { this._super.apply(this, arguments); this.set('queue', Ember.ArrayProxy.create({content: []})); }, didInsertElement() { /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} * @private */ this._uploader = new MultipleUploader(); var that = this; /** * Attach 'fileadded' event handler */ this._uploader.on('fileadded', function (qf) { that.get('queue').addObject(qf); }); /** * Attach 'fileremoved' event handler */ this._uploader.on('fileremoved', function (qf) { that.get('queue').removeObject(qf); }); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { remove(qf) { this._uploader.removeFile(qf); }, add() { this._input.click(); } } });
In template we will use an action helper to call remove action of a component:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="items"> {{#each queue as |QueuedFile index|}} <div class="item"> <div class="inner" {{action 'remove' QueuedFile bubbles=false}}> {{QueuedFile.file.name}} </div> </div> {{/each}} </div> <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
/*../app/styles/_x-uploader.scss*/ .x-uploader { background: #eeeeee; padding: 4px; margin: 1rem 0; input[type=file] { display: none; } .items { .item { padding: 3px; cursor: pointer; .inner { background: #ffffff; padding: 8px; } } } .add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; } }
Uploading files and data
To upload files, FormData and JQuery.ajax() will be used. Some info on ajax file upload may be found in google or here. Ember.RSVP.Promise will be returned from upload method.
Updated component:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader(options) { /** * Default options * * @type {{url: null, fieldName: string, headers: {}, data: {}, method: string}} * @private */ const _defaultOptions = { url: null, fieldName: 'Files[]', headers: {}, data: {}, method: 'POST' }; /** * In this array files are stored * * @type {Array} * @private */ const _files = []; /** * This object stores event handlers * * @type {{}} * @private */ const _eventHandlers = {}; /** * Merge default options with options, passed during construction */ this.options = Ember.$.extend({}, _defaultOptions, options); /** * This function triggers an event * * @param event */ const trigger = function (event) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { _eventHandlers[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { qf = new QueuedFile(files[i], true); _files.push(qf); filesAdded++; trigger('fileadded', qf); } if (filesAdded > 0) { trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will remove file * * @param qf * @returns {boolean} */ this.removeFile = function (qf) { var pos; if ((pos = _files.indexOf(qf)) > -1) { _files.splice(pos, 1); trigger('fileremoved', qf); trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method returns valid files * * @returns {Array} */ this.getValidFiles = function () { const files = []; for (var i = 0; i < _files.length; i++) { if (_files[i].valid) { files.push(_files[i]); } } return files; }; /** * This method will upload files */ this.upload = function (options) { /** * Create FormData instance */ const formData = new FormData(); /** * Merge current options with options, passed to this method. So, * if you pass an url or data to upload method, it will overwrite options passed * to constructor */ const opts = Ember.$.extend({}, this.options, options); /** * Attach data */ for (var k in opts.data) { if (opts.data.hasOwnProperty(k)) { formData.append(k, opts.data[k]); } } /** * Attach valid files */ const files = this.getValidFiles(); for (var i = 0; i < files.length; i++) { formData.append(opts.fieldName, files[i].file); } return new Ember.RSVP.Promise(function (resolve, reject) { Ember.$.ajax({ type: opts.method, url: opts.url, headers: opts.headers, data: formData, contentType: false, processData: false, error: function (jqXHR) { var error = jqXHR.responseText; try { error = JSON.parse(error); } catch (e) { error = new Ember.Error(error); } reject(error); }, success: function (data) { resolve(data); } }); }); }; /** * This method attaches event handler * * @param event * @param handler */ this.on = function (event, handler) { if (_eventHandlers[event] === undefined) { _eventHandlers[event] = []; } _eventHandlers[event].push(handler); }; /** * This method detaches event handler * * @param event * @param handler */ this.off = function (event, handler) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { if (_eventHandlers[event][i] === handler) { _eventHandlers[event].splice(i, 1); break; } } } }; } export default Ember.Component.extend({ classNames: ['x-uploader'], /** * Component's parameters */ name: "Files[]", acceptedTypes: '*/*,*', addLabel: 'Click here to add files', /** * Queue */ queue: null, onInit: null, _input: null, init() { this._super.apply(this, arguments); this.set('queue', Ember.ArrayProxy.create({content: []})); }, didInsertElement() { /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} * @private */ this._uploader = new MultipleUploader(); var that = this; /** * Attach 'fileadded' event handler */ this._uploader.on('fileadded', function (qf) { that.get('queue').addObject(qf); }); /** * Attach 'fileremoved' event handler */ this._uploader.on('fileremoved', function (qf) { that.get('queue').removeObject(qf); }); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { remove(qf) { this._uploader.removeFile(qf); }, add() { this._input.click(); } } });
In controller we need to define two actions: one to receive a reference to MultipleUploader instance and one to submit files:
/*../app/controllers/index.js*/ import Ember from 'ember'; export default Ember.Controller.extend({ url: '/upload/', title: '', _uploader: null, actions: { submit: function () { const that = this; this._uploader.upload({ url: this.get('url'), data: { title: this.get('title') } }).then( function (payload) { console.log(payload); }, function (response) { console.log(response); } ); }, uploaderInit: function (uploader) { this._uploader = uploader; } } });
Changes to main template:
{{!--../app/templates/index.hbs--}} {{input value=title}} {{x-uploader onInit='uploaderInit'}} <button {{action 'submit'}}>Send</button>
Now, if we will select files and press “Send” button, we will see that uploader works and tries to send files:
Client-side validation
In order to achieve this feature, we need to add an option validate to MultipleUploader constructor. This will be a function that returns true if file is valid and error message or false if file is invalid. If validate function will return false, file will not be added to list at all; if it will return error message, file will be added, but invalid. We will implement 4 checks: number of files check, duplicate check, file size check and file type check. To work with human-readable file sizes, numeral.js is useful. To install it we need ember-browserify. It allows to import npm packages, that are not ember addons. Then we can install numeral.
npm install --save-dev ember-browserify
npm install --save-dev numeral
Updated component:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; import numeral from 'npm:numeral'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader(options) { /** * Default options * * @type {{url: null, fieldName: string, headers: {}, data: {}, method: string}} * @private */ const _defaultOptions = { validate: function (file) { return true; }, url: null, fieldName: 'Files[]', headers: {}, data: {}, method: 'POST' }; /** * In this array files are stored * * @type {Array} * @private */ const _files = []; /** * This object stores event handlers * * @type {{}} * @private */ const _eventHandlers = {}; /** * Merge default options with options, passed during construction */ this.options = Ember.$.extend({}, _defaultOptions, options); /** * This function triggers an event * * @param event */ const trigger = function (event) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { _eventHandlers[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var errorMessage; var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { if ((errorMessage = this.options.validate(files[i])) === true) { qf = new QueuedFile(files[i], true); } else if (errorMessage === false) { continue; } else { qf = new QueuedFile(files[i], false, errorMessage); } _files.push(qf); filesAdded++; trigger('fileadded', qf); } if (filesAdded > 0) { trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will remove file * * @param qf * @returns {boolean} */ this.removeFile = function (qf) { var pos; if ((pos = _files.indexOf(qf)) > -1) { _files.splice(pos, 1); trigger('fileremoved', qf); trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method returns all files * * @returns {Array} */ this.getAllFiles = function () { return _files.slice(); }; /** * This method returns valid files * * @returns {Array} */ this.getValidFiles = function () { const files = []; for (var i = 0; i < _files.length; i++) { if (_files[i].valid) { files.push(_files[i]); } } return files; }; /** * This method will upload files */ this.upload = function (options) { /** * Create FormData instance */ const formData = new FormData(); /** * Merge current options with options, passed to this method. So, * if you pass an url or data to upload method, it will overwrite options passed * to constructor */ const opts = Ember.$.extend({}, this.options, options); /** * Attach data */ for (var k in opts.data) { if (opts.data.hasOwnProperty(k)) { formData.append(k, opts.data[k]); } } /** * Attach valid files */ const files = this.getValidFiles(); for (var i = 0; i < files.length; i++) { formData.append(opts.fieldName, files[i].file); } return new Ember.RSVP.Promise(function (resolve, reject) { Ember.$.ajax({ type: opts.method, url: opts.url, headers: opts.headers, data: formData, contentType: false, processData: false, error: function (jqXHR) { var error = jqXHR.responseText; try { error = JSON.parse(error); } catch (e) { error = new Ember.Error(error); } reject(error); }, success: function (data) { resolve(data); } }); }); }; /** * This method attaches event handler * * @param event * @param handler */ this.on = function (event, handler) { if (_eventHandlers[event] === undefined) { _eventHandlers[event] = []; } _eventHandlers[event].push(handler); }; /** * This method detaches event handler * * @param event * @param handler */ this.off = function (event, handler) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { if (_eventHandlers[event][i] === handler) { _eventHandlers[event].splice(i, 1); break; } } } }; } export default Ember.Component.extend({ classNames: ['x-uploader'], /** * Component's parameters */ name: "Files[]", acceptedTypes: '*/*,*', maxFileSize: '3MB', maxFiles: 20, addLabel: 'Click here to add files', msgWrongFileType: 'Wrong file type', msgMaxFileSize: 'File is too big ({fileSize}). Max file size is {maxFileSize}.', msgFileCounter: 'Selected {count} / {maxFiles} files', /** * Queue */ queue: null, onInit: null, _input: null, init() { this._super.apply(this, arguments); this.set('queue', Ember.ArrayProxy.create({content: []})); }, didInsertElement() { var that = this; /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} */ this._uploader = new MultipleUploader({ fieldName: this.get('name'), validate: function (file) { /** * This code will check a number of files added */ var maxFiles = that.get('maxFiles'); if (typeof maxFiles === 'number' && that._uploader.getValidFiles().length >= maxFiles) { return false; } /** * This will prevent duplicates from adding to list */ const files = that._uploader.getAllFiles(); for (var i = 0; i < files.length; i++) { if (file.name === files[i].file.name && file.size === files[i].file.size) { return false; } } /** * This will check file type */ const acceptedTypes = that.get('acceptedTypes').split(','); const escapeRegExp = /[|\\{}()[\]^$+?.]/g; var passTypeTest = false; for (i = 0; i < acceptedTypes.length; i++) { var test = new RegExp(acceptedTypes[i].replace(escapeRegExp, '\\$&').replace(/\*/g, '.*'), 'g'); if (test.test(file.type)) { passTypeTest = true; break; } } if (!passTypeTest) { return that.get('msgWrongFileType').toString(); } /** * This will check file size */ const maxFileSize = that.get('maxFileSize'); if (typeof maxFileSize === "string") { const bytes = numeral().unformat(maxFileSize); if (bytes < file.size) { return that.get('msgMaxFileSize') .toString() .replace('{fileSize}', numeral(file.size).format('0.0b')) .replace('{maxFileSize}', maxFileSize); } } return true; } }); /** * Attach 'fileadded' event handler */ this._uploader.on('fileadded', function (qf) { that.get('queue').addObject(qf); }); /** * Attach 'fileremoved' event handler */ this._uploader.on('fileremoved', function (qf) { that.get('queue').removeObject(qf); }); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { remove(qf) { this._uploader.removeFile(qf); }, add() { this._input.click(); } } });
Templates:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="items"> {{#each queue as |QueuedFile index|}} <div class="item {{if QueuedFile.valid 'valid' 'invalid'}}"> <div class="inner" {{action 'remove' QueuedFile bubbles=false}}> {{QueuedFile.file.name}} {{#unless QueuedFile.valid}} <div class="error-message"> {{QueuedFile.error}} </div> {{/unless}} </div> </div> {{/each}} </div> <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
{{!--../app/templates/index.hbs--}} {{input value=title}} {{x-uploader onInit='uploaderInit' acceptedTypes='image/*' maxFiles=5}} <button {{action 'submit'}}>Send</button>
Styles:
/*../app/styles/_x-uploader.scss*/ .x-uploader { background: #eeeeee; padding: 4px; margin: 1rem 0; input[type=file] { display: none; } .items { .item { padding: 3px; cursor: pointer; .inner { background: #ffffff; padding: 8px; } &.invalid { .inner { background: rgb(211, 47, 47); color: #ffffff; } } } } .add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; } }
Application screenshot:
Previews for images
To display previews, we need to use FileReader and canvas. When file will be added to list, we will read it and prepare thumbnail, and then fill QueuedFile‘s thumbnail property. Until then, some generic picture will be shown. In order to create a thumbnail, we will read file as data url, draw it on canvas with resize, and then use canvas‘ method toDataURL to get a small base64-encoded thumbnail. Using canvas to resize images is not necessary, but helps to reduce used memory greatly.
Component code:
/*../app/components/x-uploader.js*/ import Ember from 'ember'; import numeral from 'npm:numeral'; /** * This structure will contain a file, it's state (valid/invalid) and error message * * @param file * @param valid * @param error * @returns {*} * @constructor */ function QueuedFile(file, valid, error) { this.file = file; this.valid = !!valid; this.error = typeof error === "string" ? error : null; return Ember.Object.create(this); } /** * MultipleUploader stores files and * has a method to upload files. We will * send a reference to this object to controller, * so controller will be able to initiate upload. * * @constructor */ function MultipleUploader(options) { /** * Default options * * @type {{url: null, fieldName: string, headers: {}, data: {}, method: string}} * @private */ const _defaultOptions = { validate: function (file) { return true; }, url: null, fieldName: 'Files[]', headers: {}, data: {}, method: 'POST' }; /** * In this array files are stored * * @type {Array} * @private */ const _files = []; /** * This object stores event handlers * * @type {{}} * @private */ const _eventHandlers = {}; /** * Merge default options with options, passed during construction */ this.options = Ember.$.extend({}, _defaultOptions, options); /** * This function triggers an event * * @param event */ const trigger = function (event) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { _eventHandlers[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); } } }; /** * This method saves files from FileList to _files array * * @param files * @returns {boolean} */ this.addFiles = function (files) { var errorMessage; var qf; var filesAdded = 0; for (var i = 0; i < files.length; i++) { if ((errorMessage = this.options.validate(files[i])) === true) { qf = new QueuedFile(files[i], true); } else if (errorMessage === false) { continue; } else { qf = new QueuedFile(files[i], false, errorMessage); } _files.push(qf); filesAdded++; trigger('fileadded', qf); } if (filesAdded > 0) { trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method will remove file * * @param qf * @returns {boolean} */ this.removeFile = function (qf) { var pos; if ((pos = _files.indexOf(qf)) > -1) { _files.splice(pos, 1); trigger('fileremoved', qf); trigger('fileschanged', _files.slice()); return true; } return false; }; /** * This method returns all files * * @returns {Array} */ this.getAllFiles = function () { return _files.slice(); }; /** * This method returns valid files * * @returns {Array} */ this.getValidFiles = function () { const files = []; for (var i = 0; i < _files.length; i++) { if (_files[i].valid) { files.push(_files[i]); } } return files; }; /** * This method will upload files */ this.upload = function (options) { /** * Create FormData instance */ const formData = new FormData(); /** * Merge current options with options, passed to this method. So, * if you pass an url or data to upload method, it will overwrite options passed * to constructor */ const opts = Ember.$.extend({}, this.options, options); /** * Attach data */ for (var k in opts.data) { if (opts.data.hasOwnProperty(k)) { formData.append(k, opts.data[k]); } } /** * Attach valid files */ const files = this.getValidFiles(); for (var i = 0; i < files.length; i++) { formData.append(opts.fieldName, files[i].file); } return new Ember.RSVP.Promise(function (resolve, reject) { Ember.$.ajax({ type: opts.method, url: opts.url, headers: opts.headers, data: formData, contentType: false, processData: false, error: function (jqXHR) { var error = jqXHR.responseText; try { error = JSON.parse(error); } catch (e) { error = new Ember.Error(error); } reject(error); }, success: function (data) { resolve(data); } }); }); }; /** * This method attaches event handler * * @param event * @param handler */ this.on = function (event, handler) { if (_eventHandlers[event] === undefined) { _eventHandlers[event] = []; } _eventHandlers[event].push(handler); }; /** * This method detaches event handler * * @param event * @param handler */ this.off = function (event, handler) { if (_eventHandlers[event] !== undefined) { for (var i = 0; i < _eventHandlers[event].length; i++) { if (_eventHandlers[event][i] === handler) { _eventHandlers[event].splice(i, 1); break; } } } }; } export default Ember.Component.extend({ classNames: ['x-uploader'], /** * Component's parameters */ name: "Files[]", acceptedTypes: '*/*,*', maxFileSize: '3MB', maxFiles: 20, thumbWidth: 300, thumbHeight: 169, addLabel: 'Click here to add files', msgWrongFileType: 'Wrong file type', msgMaxFileSize: 'File is too big ({fileSize}). Max file size is {maxFileSize}.', msgFileCounter: 'Selected {count} / {maxFiles} files', /** * Queue */ queue: null, onInit: null, _input: null, init() { this._super.apply(this, arguments); this.set('queue', Ember.ArrayProxy.create({content: []})); }, didInsertElement() { var that = this; /** * Create an instance of MultipleUploader and save it * * @type {MultipleUploader} */ this._uploader = new MultipleUploader({ fieldName: this.get('name'), validate: function (file) { /** * This code will check a number of files added */ var maxFiles = that.get('maxFiles'); if (typeof maxFiles === 'number' && that._uploader.getValidFiles().length >= maxFiles) { return false; } /** * This will prevent duplicates from adding to list */ const files = that._uploader.getAllFiles(); for (var i = 0; i < files.length; i++) { if (file.name === files[i].file.name && file.size === files[i].file.size) { return false; } } /** * This will check file type */ const acceptedTypes = that.get('acceptedTypes').split(','); const escapeRegExp = /[|\\{}()[\]^$+?.]/g; var passTypeTest = false; for (i = 0; i < acceptedTypes.length; i++) { var test = new RegExp(acceptedTypes[i].replace(escapeRegExp, '\\$&').replace(/\*/g, '.*'), 'g'); if (test.test(file.type)) { passTypeTest = true; break; } } if (!passTypeTest) { return that.get('msgWrongFileType').toString(); } /** * This will check file size */ const maxFileSize = that.get('maxFileSize'); if (typeof maxFileSize === "string") { const bytes = numeral().unformat(maxFileSize); if (bytes < file.size) { return that.get('msgMaxFileSize') .toString() .replace('{fileSize}', numeral(file.size).format('0.0b')) .replace('{maxFileSize}', maxFileSize); } } return true; } }); /** * Attach 'fileadded' event handler */ this._uploader.on('fileadded', function (qf) { that.get('queue').addObject(qf); const imageType = /^image\//; const thumbWidth = that.get('thumbWidth'); const thumbHeight = that.get('thumbHeight'); if (imageType.test(qf.file.type)) { var reader = new FileReader(); reader.onload = function (e) { var image = new Image(); image.src = reader.result; image.onload = function () { var width = image.width; var height = image.height; var shouldResize = (width > thumbWidth) && (height > thumbHeight); var newWidth; var newHeight; if (shouldResize) { const largest = Math.max(thumbHeight, thumbWidth); if (width > height) { newWidth = width * (largest / height); newHeight = largest; } else { newHeight = height * (largest / width); newWidth = largest; } } else { newWidth = width; newHeight = height; } var canvas = document.createElement('canvas'); canvas.width = thumbWidth; canvas.height = thumbHeight; var context = canvas.getContext('2d'); context.drawImage(this, (thumbWidth - newWidth) / 2, (thumbHeight - newHeight) / 2, newWidth, newHeight); qf.set('thumbnail', canvas.toDataURL()); }; }; Ember.run.once(reader, 'readAsDataURL', qf.file); } }); /** * Attach 'fileremoved' event handler */ this._uploader.on('fileremoved', function (qf) { that.get('queue').removeObject(qf); }); this._generateInput(); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader); }, /** * Generates file input that needed to open file selection dialog * * @private */ _generateInput() { const that = this; /** * If we already have an input, remove it */ if (this._input && this._input.remove) { this._input.remove(); } /** * Create new input */ this._input = Ember.$('<input type="file" name="' + this.get('name') + '" accept="' + this.get('acceptedTypes') + '" multiple="multiple" />'); /** * On change (user selected files) add files to upload and regenerate input to clear it */ this._input.on('change', function () { that._uploader.addFiles(this.files); that._generateInput(); }); /** * Append input to component */ this._input.appendTo(this.element); }, actions: { remove(qf) { this._uploader.removeFile(qf); }, add() { this._input.click(); } } });
Template:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="items"> {{#each queue as |QueuedFile index|}} <div class="item {{if QueuedFile.valid 'valid' 'invalid'}}"> <div class="inner" {{action 'remove' QueuedFile bubbles=false}}> <div class="thumbnail"> {{#if QueuedFile.thumbnail}} <img src="{{QueuedFile.thumbnail}}"/> {{else}} <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhE UgAAASwAAACpCAYAAACRdwCqAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ bWFnZVJlYWR5ccllPAAAAyFpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdp bj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6 eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1 NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJo dHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlw dGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAv IiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RS ZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpD cmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgeG1wTU06SW5zdGFuY2VJ RD0ieG1wLmlpZDpCNDU5MDQ1NThGQ0ExMUU1QkY2OUFENkM0ODVCRkYwQyIgeG1wTU06RG9jdW1l bnRJRD0ieG1wLmRpZDpCNDU5MDQ1NjhGQ0ExMUU1QkY2OUFENkM0ODVCRkYwQyI+IDx4bXBNTTpE ZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkI0NTkwNDUzOEZDQTExRTVCRjY5 QUQ2QzQ4NUJGRjBDIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkI0NTkwNDU0OEZDQTExRTVC RjY5QUQ2QzQ4NUJGRjBDIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBt ZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+DSCcAQAAA4FJREFUeNrs3MFqE0EYwPEd09LosRcFxUPB UkT7QD5HX8OCLyH4NHoQKyiC0h4UerZV0/FbuiDmkNSakpmd3w8+CoUenG3+7k6aSTnnDqAGtywB IFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGC BSBYAIIF1GbDEpQvpfQ/P/7YCl5NzvmdVSj8tRAXySqMO1jvY2ZWcalJvBb2LINgsd5gucBXv8NK VqFs9rAAwQJYNZvu7TmL+TJ8bfER6CLmTszDmC2/DoJF2U5iDmKOYjYb/Pf/iHkScxiz49dBsCjb ecyHmI8Nr8F0WAcqYw+rPf1j4KTxNZg0+jgsWACChbsLmGMPq41HQHCHBSBYAIIF1MweVh3uxWxb hqVOY75aBsFivV7EPO2cvLBI/+bCm5hnlmLEF9nxMhVcpJRcpH8L1zL7Ma9i/jr/yvEy5bOHBQgW wKrZw6pT/8Hdz50jYhwRI1hU4LhzREz/JsTzzhExgkUVd1iOiHFETHPsYdXJETE+xC1YAIIFIFiA YAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgW gGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUI FoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWLA+s5hsGeqzYQkYua2YBzHTIVI/ Yx4N30ewoCh9rA5j9mJ+DdHq43Xf0ggWlGY6xGrXUtTPHhZjl4c7KwQLipdiNi2DR0KowfeYt8Nd 1qI7rYmlEixYt5OYg+7Pu4QIFhTrPOaTZRgHe1iAYAEIFiBYAIIFIFiAYFGD/u+JZo2vgSNiGuTv sOrUH42yO7xoW/zYiSNiGpVy9p9U8RcppfmL1P8x5HHMWXf5WbnWXMTc7i6PjpmP1rXXw2tBsLiZ YLFguQRrvOxhAYIFsGo23evwMma/867YskfB15Zh5BfZc3sFFymlu/Fl20osdRrz7bo/7LUgWAAr Yw8LECwAwQIEC0CwAAQLECwAwQIQLECwAAQLQLAAwQIQLADBAgQLQLAABAsQLADBAhAsQLAABAtA sADBAhAsAMECBAvgpvwWYADV43NNqeinRQAAAABJRU5ErkJggg=="/> {{/if}} {{#unless QueuedFile.valid}} <div class="error-message"> <div class="text"> {{QueuedFile.error}} </div> </div> {{/unless}} </div> <div class="info"> {{QueuedFile.file.name}} </div> </div> </div> {{/each}} </div> <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
Styles:
/*../app/styles/_x-uploader.scss*/ .x-uploader { background: #eeeeee; padding: 4px; margin: 1rem 0; input[type=file] { display: none; } .items { .item { padding: 3px; float: left; width: 283px; cursor: pointer; .inner { background: #ffffff; padding: 8px; .thumbnail { background: #ffffff; img { width: 100%; max-width: 300px; display: block; } } .info { height: 1.5em; overflow: hidden; } .error-message { display: none; } &:hover { .thumbnail { position: relative; .error-message { display: block; position: absolute; background: rgba(211, 47, 47, 0.9); color: #ffffff; top: 0; left: 0; width: 100%; height: 100%; overflow-y: auto; .text { position: relative; top: 50%; -webkit-transform: translateY(-50%); -ms-transform: translateY(-50%); transform: translateY(-50%); text-align: center; } } } } } &.invalid { .inner { background: rgb(211, 47, 47); color: #ffffff; } } } &:after { content: ""; display: table; clear: both; } } .add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; } }
Screenshot:
Server-side errors/validation
To show server-side errors, let’s pass to our component an array, containing error messages.
Controller:
/*../app/controllers/index.js*/ import Ember from 'ember'; export default Ember.Controller.extend({ url: '/upload/', title: '', _uploader: null, errors: [], actions: { submit: function () { const that = this; this.set('errors', []); this._uploader.upload({ url: this.get('url'), data: { title: this.get('title') } }).then( function (payload) { console.log(payload); }, function (response) { /** * Here you should parse response from your server and form an array of errors */ if (response.message) { that.set('errors', [response.message, null, 'Something happen']); } console.log(response); } ); }, uploaderInit: function (uploader) { this._uploader = uploader; } } });
In template we need to access array’s element by it’s index and combine conditions. As handlebars doesn’t support such things, let’s create 3 useful helpers:
/*../app/helpers/array-item.js*/ import Ember from 'ember'; export default Ember.Handlebars.makeBoundHelper(function (array, index) { return array !== undefined && array[index] !== undefined ? array[index] : undefined; });
/*../app/helpers/is-not.js*/ import Ember from 'ember'; export function isNot([value]) { return !value; } export default Ember.Helper.helper(isNot);
/*../app/helpers/logical-or.js*/ import Ember from 'ember'; export function logicalOr(params) { var result = false; for (var i = 0; i < params.length; i++) { result = result || !!params[i]; } return result; } export default Ember.Helper.helper(logicalOr);
Component’s template:
{{!-- ../app/templates/components/x-uploader.hbs --}} <div class="items"> {{#each queue as |QueuedFile index|}} <div class="item {{if (logical-or (is-not QueuedFile.valid) (array-item errors index)) 'invalid' 'valid'}}" {{action 'remove' QueuedFile bubbles=false}}> <div class="inner"> <div class="thumbnail"> {{#if QueuedFile.thumbnail}} <img src="{{QueuedFile.thumbnail}}"/> {{else}} <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhE UgAAASwAAACpCAYAAACRdwCqAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJ bWFnZVJlYWR5ccllPAAAAyFpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdp bj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6 eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNS1jMDIxIDc5LjE1 NDkxMSwgMjAxMy8xMC8yOS0xMTo0NzoxNiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJo dHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlw dGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAv IiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RS ZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpD cmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKSIgeG1wTU06SW5zdGFuY2VJ RD0ieG1wLmlpZDpCNDU5MDQ1NThGQ0ExMUU1QkY2OUFENkM0ODVCRkYwQyIgeG1wTU06RG9jdW1l bnRJRD0ieG1wLmRpZDpCNDU5MDQ1NjhGQ0ExMUU1QkY2OUFENkM0ODVCRkYwQyI+IDx4bXBNTTpE ZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOkI0NTkwNDUzOEZDQTExRTVCRjY5 QUQ2QzQ4NUJGRjBDIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkI0NTkwNDU0OEZDQTExRTVC RjY5QUQ2QzQ4NUJGRjBDIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBt ZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+DSCcAQAAA4FJREFUeNrs3MFqE0EYwPEd09LosRcFxUPB UkT7QD5HX8OCLyH4NHoQKyiC0h4UerZV0/FbuiDmkNSakpmd3w8+CoUenG3+7k6aSTnnDqAGtywB IFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGC BSBYAIIF1GbDEpQvpfQ/P/7YCl5NzvmdVSj8tRAXySqMO1jvY2ZWcalJvBb2LINgsd5gucBXv8NK VqFs9rAAwQJYNZvu7TmL+TJ8bfER6CLmTszDmC2/DoJF2U5iDmKOYjYb/Pf/iHkScxiz49dBsCjb ecyHmI8Nr8F0WAcqYw+rPf1j4KTxNZg0+jgsWACChbsLmGMPq41HQHCHBSBYAIIF1MweVh3uxWxb hqVOY75aBsFivV7EPO2cvLBI/+bCm5hnlmLEF9nxMhVcpJRcpH8L1zL7Ma9i/jr/yvEy5bOHBQgW wKrZw6pT/8Hdz50jYhwRI1hU4LhzREz/JsTzzhExgkUVd1iOiHFETHPsYdXJETE+xC1YAIIFIFiA YAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgW gGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUI FoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWLA+s5hsGeqzYQkYua2YBzHTIVI/ Yx4N30ewoCh9rA5j9mJ+DdHq43Xf0ggWlGY6xGrXUtTPHhZjl4c7KwQLipdiNi2DR0KowfeYt8Nd 1qI7rYmlEixYt5OYg+7Pu4QIFhTrPOaTZRgHe1iAYAEIFiBYAIIFIFiAYFGD/u+JZo2vgSNiGuTv sOrUH42yO7xoW/zYiSNiGpVy9p9U8RcppfmL1P8x5HHMWXf5WbnWXMTc7i6PjpmP1rXXw2tBsLiZ YLFguQRrvOxhAYIFsGo23evwMma/867YskfB15Zh5BfZc3sFFymlu/Fl20osdRrz7bo/7LUgWAAr Yw8LECwAwQIEC0CwAAQLECwAwQIQLECwAAQLQLAAwQIQLADBAgQLQLAABAsQLADBAhAsQLAABAtA sADBAhAsAMECBAvgpvwWYADV43NNqeinRQAAAABJRU5ErkJggg=="/> {{/if}} {{#if (logical-or (is-not QueuedFile.valid) (array-item errors index))}} <div class="error-message"> <div class="text"> {{#if QueuedFile.error}}{{QueuedFile.error}}{{else}}{{array-item errors index}}{{/if}} </div> </div> {{/if}} </div> <div class="info"> {{QueuedFile.file.name}} </div> </div> </div> {{/each}} </div> <div class="add-file" {{action 'add'}}>{{addLabel}}</div>
Index template:
{{!--../app/templates/index.hbs--}} {{input value=title}} {{x-uploader onInit='uploaderInit' acceptedTypes='image/*' maxFiles=5 errors=errors}} <button {{action 'submit'}}>Send</button>
Drag-and-drop support
This is easy – just attach a drop event handler to desired element. Event.originalEvent.dataTransfer.files will contain a FileList object.
Add these lines to component (in didInsertElement hook):
this._generateInput(); const dropzone = Ember.$(this.element); dropzone.on('dragover', function (event) { event.preventDefault(); event.stopPropagation(); dropzone.addClass('hover'); return false; }); dropzone.on('dragleave', function (event) { event.preventDefault(); event.stopPropagation(); dropzone.removeClass('hover'); return false; }); dropzone.on('drop', function (event) { event.preventDefault(); event.stopPropagation(); that._uploader.addFiles(event.originalEvent.dataTransfer.files); dropzone.removeClass('hover'); dropzone.addClass('drop'); }); /** * Send action to controller so it will be able to use _uploader object to initiate upload */ this.sendAction('onInit', this._uploader);
Counter
If would be great to inform user how many files they added to upload queue and how many they can add. To do this, we can add to component a computed property, that depends on queue:
msgFileCounter: 'Selected {count} / {maxFiles} files', /** * Queue */ queue: null, onInit: null, _input: null, counter: Ember.computed('queue.@each', function () { var count = 0; if (this._uploader) { count = this._uploader.getValidFiles().length; } var maxFiles = this.get('maxFiles'); return this.get('msgFileCounter').toString().replace('{count}', count).replace('{maxFiles}', maxFiles); }), init() {
In component’s template, put counter somewhere:
<div class="add-file" {{action 'add'}}>{{addLabel}}<div class="counter">{{counter}}</div></div>
And adjust it’s position:
.add-file { height: 50px; cursor: pointer; text-align: center; line-height: 50px; position: relative;; .counter { position: absolute; right: 0; bottom: 0; text-align: right; } }
That’s all. If you have any comments, leave them below.
UPD: Here is a good artcicle about drag and drop upload, UX and compatibility with old browsers. Read it if you want to make your uploader even better.