├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md └── src └── angular-file.js /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 0.1.1 - 2015-08-19 6 | * Adds a CHANGELOG 7 | 8 | ## 0.1.0 - 2015-08-11 9 | * Initial Release 10 | * Adds License 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Pull Requests 4 | 5 | 1. [Fork Project](https://help.github.com/articles/fork-a-repo/) 6 | 2. Create your feature branch (`git checkout -b my-new-feature`) 7 | 3. Commit your changes (`git commit -am 'Added some feature'`) 8 | 4. Push your changes (`git push -u origin my-new-feature`) 9 | 5. [Create new Pull Request](https://help.github.com/articles/using-pull-requests/#initiating-the-pull-request) 10 | 11 | Also, please include the following: 12 | 13 | * Tests 14 | * An update to the [CHANGELOG.md](CHANGELOG.MD) 15 | 16 | ## Bugs and Other Issues 17 | 18 | * Report problems to the project's [issue tracker](https://github.com/radify/angular-file/issues). 19 | * You can help us diagnose and fix existing bugs by asking and providing answers for the following: 20 | * Is the bug reproducible as explained? 21 | * Is it reproducible in other environments (for instance, on different browsers or devices)? 22 | * Are the steps to reproduce the bug clear? If not, can you describe how you might reproduce it? 23 | * What tags should the bug have? 24 | * Is this bug something you have run into? Would you appreciate it being looked into faster? 25 | 26 | **IMPORTANT**: By submitting a patch, you agree to the terms of the [BSD-3 License](LICENSE). 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Radify, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of angular-file nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ur.file: Native HTML5-based file input bindings for AngularJS 2 | 3 | ### Example 4 | 5 | ```html 6 | 7 | 8 | 9 | 10 | 11 | 30 | 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | ### What's happening here? 38 | 39 | * `ng-model`: You can now use it for `` elements, just like normal. Bind it to a scope property, and it will be assigned a `File` object when the file input is populated. However, this is effectively a read-only property, due to the security restrictions around manipulating file uploads with JavaScript. 40 | 41 | * `change`: Typical change event. Triggered when a file is selected. 42 | 43 | * `Files.prototype.$save.call()`: Treats the file object as an instance of `$resource`, and POSTs the raw contents of the file to the configured URL. The upload handler also sets four headers: `X-File-Name`, `X-File-Size`, `X-File-Last-Modified`, and `Content-Type`. 44 | 45 | The `X-File-Name` header is encoded with URL encoding. 46 | -------------------------------------------------------------------------------- /src/angular-file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ur.file: Native HTML5-based file input bindings for AngularJS 3 | * 4 | * @version 0.9a 5 | * @copyright (c) 2013 Union of RAD, LLC http://union-of-rad.com/ 6 | * @license: BSD 7 | */ 8 | 9 | 10 | /** 11 | * The ur.file module implements native support for file uploads in AngularJS. 12 | */ 13 | angular.module('ur.file', []).config(['$provide', function($provide) { 14 | 15 | /** 16 | * XHR initialization, copied from Angular core, because it's buried inside $HttpProvider. 17 | */ 18 | var XHR = window.XMLHttpRequest || function() { 19 | try { return new ActiveXObject("Msxml2.XMLHTTP.6.0"); } catch (e1) {} 20 | try { return new ActiveXObject("Msxml2.XMLHTTP.3.0"); } catch (e2) {} 21 | try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch (e3) {} 22 | throw new Error("This browser does not support XMLHttpRequest."); 23 | }; 24 | 25 | /** 26 | * Initializes XHR object with parameters from $httpBackend. 27 | */ 28 | function prepXHR(method, url, headers, callback, withCredentials, type, manager) { 29 | var xhr = new XHR(); 30 | var status; 31 | 32 | xhr.open(method, url, true); 33 | 34 | if (type) { 35 | xhr.type = type; 36 | headers['Content-Type'] = type; 37 | } 38 | 39 | angular.forEach(headers, function(value, key) { 40 | (value) ? xhr.setRequestHeader(key, value) : null; 41 | }); 42 | 43 | manager.register(xhr); 44 | 45 | xhr.onreadystatechange = function() { 46 | if (xhr.readyState == 4) { 47 | manager.unregister(xhr); 48 | var response = xhr.response || xhr.responseText; 49 | callback(status = status || xhr.status, response, xhr.getAllResponseHeaders()); 50 | } 51 | }; 52 | 53 | if (withCredentials) { 54 | xhr.withCredentials = true; 55 | } 56 | return xhr; 57 | } 58 | 59 | /** 60 | * Hook into $httpBackend to intercept requests containing files. 61 | */ 62 | $provide.decorator('$httpBackend', ['$delegate', '$window', 'uploadManager', function($delegate, $window, uploadManager) { 63 | return function(method, url, post, callback, headers, timeout, wc) { 64 | var containsFile = false, result = null, manager = uploadManager; 65 | 66 | if (post && angular.isObject(post)) { 67 | containsFile = hasFile(post); 68 | } 69 | 70 | if (angular.isObject(post)) { 71 | if (post && post.name && !headers['X-File-Name']) { 72 | headers['X-File-Name'] = encodeURI(post.name); 73 | } 74 | 75 | angular.forEach({ 76 | size: 'X-File-Size', 77 | lastModifiedDate: 'X-File-Last-Modified' 78 | }, function(header, key) { 79 | if (post && post[key]) { 80 | if (!headers[header]) headers[header] = post[key]; 81 | } 82 | }); 83 | } 84 | 85 | if (post && post instanceof Blob) { 86 | return prepXHR(method, url, headers, callback, wc, post.type, manager).send(post); 87 | } 88 | $delegate(method, url, post, callback, headers, timeout, wc); 89 | }; 90 | }]); 91 | 92 | /** 93 | * Checks an object hash to see if it contains a File object, or, if legacy is true, checks to 94 | * see if an object hash contains an element. 95 | */ 96 | var hasFile = function(data) { 97 | for (var n in data) { 98 | if (data[n] instanceof Blob) { 99 | return true; 100 | } 101 | if ((angular.isObject(data[n]) || angular.isArray(data[n])) && hasFile(data[n])) { 102 | return true; 103 | } 104 | } 105 | return false; 106 | }; 107 | 108 | /** 109 | * Prevents $http from executing its default transformation behavior if the data to be 110 | * transformed contains file data. 111 | */ 112 | $provide.decorator('$http', ['$delegate', function($delegate) { 113 | var transformer = $delegate.defaults.transformRequest[0]; 114 | 115 | $delegate.defaults.transformRequest = [function(data) { 116 | return data instanceof Blob ? data : transformer(data); 117 | }]; 118 | return $delegate; 119 | }]); 120 | 121 | }]).service('fileHandler', ['$q', '$rootScope', function($q, $rootScope) { 122 | 123 | return { 124 | 125 | /** 126 | * Loads a file as a data URL and returns a promise representing the file's value. 127 | */ 128 | load: function(file) { 129 | var deferred = $q.defer(); 130 | 131 | var reader = angular.extend(new FileReader(), { 132 | onload: function(e) { 133 | deferred.resolve(e.target.result); 134 | if (!$rootScope.$$phase) $rootScope.$apply(); 135 | }, 136 | onerror: function(e) { 137 | deferred.reject(e); 138 | if (!$rootScope.$$phase) $rootScope.$apply(); 139 | }, 140 | onabort: function(e) { 141 | deferred.reject(e); 142 | if (!$rootScope.$$phase) $rootScope.$apply(); 143 | } 144 | // onprogress: Gee, it'd be great to get some progress support from $q... 145 | }); 146 | reader.readAsDataURL(file); 147 | 148 | return angular.extend(deferred.promise, { 149 | abort: function() { reader.abort(); } 150 | }); 151 | }, 152 | 153 | /** 154 | * Returns the metadata from a File object, including the name, size and last modified date. 155 | */ 156 | meta: function(file) { 157 | return { 158 | name: file.name, 159 | size: file.size, 160 | lastModifiedDate: file.lastModifiedDate 161 | }; 162 | }, 163 | 164 | /** 165 | * Converts a File object or data URL to a Blob. 166 | */ 167 | toBlob: function(data) { 168 | var extras = {}; 169 | 170 | if (data instanceof File) { 171 | extras = this.meta(data); 172 | data = data.toDataURL(); 173 | } 174 | var parts = data.split(","), headers = parts[0].split(":"), body; 175 | 176 | if (parts.length !== 2 || headers.length !== 2 || headers[0] !== "data") { 177 | throw new Error("Invalid data URI."); 178 | } 179 | headers = headers[1].split(";"); 180 | body = (headers[1] === "base64") ? atob(parts[1]) : decodeURI(parts[1]); 181 | var length = body.length, buffer = new ArrayBuffer(length), bytes = new Uint8Array(buffer); 182 | 183 | for (var i = 0; i < length; i++) { 184 | bytes[i] = body.charCodeAt(i); 185 | } 186 | return angular.extend(new Blob([bytes], { type: headers[0] }), extras); 187 | } 188 | }; 189 | 190 | }]).service('uploadManager', ['$rootScope', function($rootScope) { 191 | 192 | angular.extend(this, { 193 | id : null, 194 | uploads: {}, 195 | capture: function(id) { 196 | this.id = id; 197 | this.uploads[id] = { 198 | loaded: 0, 199 | total: 0, 200 | percent: 0, 201 | object: null 202 | }; 203 | }, 204 | register: function(xhr) { 205 | if (this.id === null) { 206 | return false; 207 | } 208 | xhr._idXhr = this.id; 209 | this.id = null; 210 | this.uploads[xhr._idXhr]['object'] = xhr; 211 | var self = this; 212 | 213 | xhr.upload.onprogress = function(e) { 214 | if (e.lengthComputable) { 215 | self.uploads[xhr._idXhr]['loaded'] = e.loaded; 216 | self.uploads[xhr._idXhr]['total'] = e.total; 217 | self.uploads[xhr._idXhr]['percent'] = Math.round(e.loaded / e.total * 100); 218 | $rootScope.$apply(); 219 | } 220 | }; 221 | return true; 222 | }, 223 | unregister: function(xhr) { 224 | delete this.uploads[xhr._idXhr]; 225 | }, 226 | get: function(id) { 227 | if (this.uploads[id]) { 228 | return this.uploads[id]; 229 | } 230 | return false; 231 | }, 232 | abort: function(id) { 233 | if (this.uploads[id]) { 234 | return this.uploads[id]['object'].abort(); 235 | } 236 | return false; 237 | } 238 | }); 239 | 240 | }]).directive('type', ['$parse', function urModelFileFactory($parse) { 241 | 242 | /** 243 | * Binding for file input elements 244 | */ 245 | return { 246 | scope: false, 247 | priority: 1, 248 | require: "?ngModel", 249 | link: function urFilePostLink(scope, element, attrs, ngModel) { 250 | 251 | if (attrs.type.toLowerCase() !== 'file' || !ngModel) { 252 | return; 253 | } 254 | 255 | element.bind('change', function(e) { 256 | if (!e.target.files || !e.target.files.length || !e.target.files[0]) { 257 | return true; 258 | } 259 | var index, fileData = attrs.multiple ? e.target.files : e.target.files[0]; 260 | ngModel.$render = function() {}; 261 | 262 | scope.$apply(function(scope) { 263 | index = scope.$index; 264 | $parse(attrs.ngModel).assign(scope, fileData); 265 | }); 266 | scope.$index = index; 267 | 268 | // @todo Make sure this can be replaced by ngChange. 269 | // For that to work, this event handler must have a higher priority than the one 270 | // defined by ngChange 271 | attrs.change ? scope.$eval(attrs.change) : null; 272 | }); 273 | } 274 | }; 275 | 276 | }]).directive('dropTarget', ['$parse', 'fileHandler', function urDropTargetFactory($parse, fileHandler) { 277 | 278 | return { 279 | scope: false, 280 | restrict: "EAC", 281 | require: "?ngModel", 282 | link: function urDropTargetLink(scope, element, attrs, ngModel) { 283 | var multiple = attrs.multiple, 284 | dropExpr = attrs.drop ? $parse(attrs.drop) : null, 285 | modelExpr = attrs.ngModel ? $parse(attrs.ngModel) : null; 286 | 287 | if (ngModel) ngModel.$render = function() {}; 288 | 289 | function stop(e) { 290 | e.stopPropagation(); 291 | e.preventDefault(); 292 | } 293 | 294 | var toIgnore = [], isOver = false; 295 | 296 | element.bind("dragenter", function dragEnter(e) { 297 | stop(e); 298 | if (e.target === this && !isOver) { 299 | if (attrs.overClass) element.addClass(attrs.overClass); 300 | isOver = true; 301 | return; 302 | } 303 | toIgnore.push(e.target); 304 | }); 305 | 306 | element.bind("dragleave", function dragExit(e) { 307 | stop(e); 308 | if (toIgnore.length === 0 && isOver) { 309 | if (attrs.overClass) element.removeClass(attrs.overClass); 310 | isOver = false; 311 | return; 312 | } 313 | toIgnore.pop(); 314 | }); 315 | 316 | element.bind("dragover", function(e) { 317 | stop(e); 318 | }); 319 | 320 | element.bind("drop", function(e) { 321 | stop(e); 322 | if (attrs.overClass) element.removeClass(attrs.overClass); 323 | isOver = false; 324 | e = e.originalEvent || e; 325 | var files = e.dataTransfer.files; 326 | 327 | if (!files.length) return; 328 | files = multiple ? files : files[0]; 329 | 330 | if (modelExpr) modelExpr.assign(scope, files); 331 | if (!dropExpr) return (scope.$$phase) ? null : scope.$apply(); 332 | 333 | var local = { $event: e }; 334 | local['$file' + (multiple ? 's' : '')] = files; 335 | var result = function() { dropExpr(scope, local); }; 336 | (scope.$$phase) ? result() : scope.$apply(result); 337 | }); 338 | } 339 | }; 340 | 341 | }]); 342 | --------------------------------------------------------------------------------