diff --git a/components/file-upload/file-drop.ts b/components/file-upload/file-drop.ts new file mode 100644 index 0000000..20c01fe --- /dev/null +++ b/components/file-upload/file-drop.ts @@ -0,0 +1,98 @@ +import { + Component, View, OnInit, OnDestroy, OnChanges, + Directive, LifecycleEvent, + EventEmitter, ElementRef, Renderer, + CORE_DIRECTIVES, FORM_DIRECTIVES, NgClass +} from 'angular2/angular2'; + +import {FileUploader} from './file-uploader'; + +@Directive({ + selector: '[ng2-file-drop]', + properties: ['config: ng2FileDrop', 'uploader'], + events: ['fileOver'], + host: { + '(drop)': 'onDrop($event)', + '(dragover)': 'onDragOver($event)', + '(dragleave)': 'onDragLeave($event)' + } +}) +export class FileDrop { + public uploader:FileUploader; + public config:any = {}; + private fileOver:EventEmitter = new EventEmitter(); + + constructor(private element:ElementRef) { + } + + getOptions() { + return this.uploader.options; + } + + getFilters() { + } + + onDrop(event) { + let transfer = this._getTransfer(event); + if (!transfer) { + return; + } + + let options = this.getOptions(); + let filters = this.getFilters(); + this._preventAndStop(event); + this.uploader.addToQueue(transfer.files, options, filters); + this.fileOver.next(false); + } + + onDragOver(event) { + let transfer = this._getTransfer(event); + if (!this._haveFiles(transfer.types)) { + return; + } + + transfer.dropEffect = 'copy'; + this._preventAndStop(event); + this.fileOver.next(true); + } + + onDragLeave(event) { + if (event.currentTarget === this.element[0]) { + return; + } + + this._preventAndStop(event); + this.fileOver.next(false); + } + + _getTransfer(event) { + return event.dataTransfer ? event.dataTransfer : event.originalEvent.dataTransfer; // jQuery fix; + } + + _preventAndStop(event) { + event.preventDefault(); + event.stopPropagation(); + } + + _haveFiles(types) { + if (!types) { + return false; + } + + if (types.indexOf) { + return types.indexOf('Files') !== -1; + } else if (types.contains) { + return types.contains('Files'); + } else { + return false; + } + } + + _addOverClass(item) { + item.addOverClass(); + } + + _removeOverClass(item) { + item.removeOverClass(); + } +} diff --git a/components/file-upload/file-item.ts b/components/file-upload/file-item.ts new file mode 100644 index 0000000..4ac9b93 --- /dev/null +++ b/components/file-upload/file-item.ts @@ -0,0 +1,134 @@ +import {FileLikeObject} from './file-like-object'; +import {FileUploader} from './file-uploader'; +import Form = ng.Form; + +export class FileItem { + public file:FileLikeObject; + public _file:File; + public alias:string = 'file'; + public url:string = '/'; + public method:string = 'POST'; + public headers:any = []; + public withCredentials:boolean = true; + public formData:any = []; + public isReady:boolean = false; + public isUploading:boolean = false; + public isUploaded:boolean = false; + public isSuccess:boolean = false; + public isCancel:boolean = false; + public isError:boolean = false; + public progress:number = 0; + public index:number = null; + + constructor(private uploader:FileUploader, private some:any, private options:any) { + this.file = new FileLikeObject(some); + this._file = some; + this.url = uploader.url; + } + + public upload() { + try { + this.uploader.uploadItem(this); + } catch (e) { + this.uploader._onCompleteItem(this, '', 0, []); + this.uploader._onErrorItem(this, '', 0, []); + } + } + + public cancel() { + this.uploader.cancelItem(this); + } + + public remove() { + this.uploader.removeFromQueue(this); + } + + public onBeforeUpload() { + } + + public onProgress(progress) { + } + + public onSuccess(response, status, headers) { + } + + public onError(response, status, headers) { + } + + public onCancel(response, status, headers) { + } + + public onComplete(response, status, headers) { + } + + private _onBeforeUpload() { + this.isReady = true; + this.isUploading = true; + this.isUploaded = false; + this.isSuccess = false; + this.isCancel = false; + this.isError = false; + this.progress = 0; + this.onBeforeUpload(); + } + + private _onProgress(progress) { + this.progress = progress; + this.onProgress(progress); + } + + private _onSuccess(response, status, headers) { + this.isReady = false; + this.isUploading = false; + this.isUploaded = true; + this.isSuccess = true; + this.isCancel = false; + this.isError = false; + this.progress = 100; + this.index = null; + this.onSuccess(response, status, headers); + } + + private _onError(response, status, headers) { + this.isReady = false; + this.isUploading = false; + this.isUploaded = true; + this.isSuccess = false; + this.isCancel = false; + this.isError = true; + this.progress = 0; + this.index = null; + this.onError(response, status, headers); + } + + private _onCancel(response, status, headers) { + this.isReady = false; + this.isUploading = false; + this.isUploaded = false; + this.isSuccess = false; + this.isCancel = true; + this.isError = false; + this.progress = 0; + this.index = null; + this.onCancel(response, status, headers); + } + + private _onComplete(response, status, headers) { + this.onComplete(response, status, headers); + + if (this.uploader.removeAfterUpload) { + this.remove(); + } + } + + private _destroy() { + } + + private _prepareToUploading() { + this.index = this.index || ++this.uploader._nextIndex; + this.isReady = true; + } + + _replaceNode(input) { + } +} diff --git a/components/file-upload/file-like-object.ts b/components/file-upload/file-like-object.ts new file mode 100644 index 0000000..78e7829 --- /dev/null +++ b/components/file-upload/file-like-object.ts @@ -0,0 +1,32 @@ +function isElement(node) { + return !!(node && (node.nodeName || node.prop && node.attr && node.find)); +} + +export class FileLikeObject { + public lastModifiedDate:any; + public size:any; + public type:string; + public name:string; + + constructor(fileOrInput:any) { + let isInput = isElement(fileOrInput); + let fakePathOrObject = isInput ? fileOrInput.value : fileOrInput; + let postfix = typeof fakePathOrObject === 'string' ? 'FakePath' : 'Object'; + let method = '_createFrom' + postfix; + this[method](fakePathOrObject); + } + + public _createFromFakePath(path) { + this.lastModifiedDate = null; + this.size = null; + this.type = 'like/' + path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + this.name = path.slice(path.lastIndexOf('/') + path.lastIndexOf('\\') + 2); + } + + public _createFromObject(object) { + // this.lastModifiedDate = copy(object.lastModifiedDate); + this.size = object.size; + this.type = object.type; + this.name = object.name; + } +} diff --git a/components/file-upload/file-select.ts b/components/file-upload/file-select.ts index d90d5fc..3dc31e2 100644 --- a/components/file-upload/file-select.ts +++ b/components/file-upload/file-select.ts @@ -1,21 +1,56 @@ /// import { - Component, View, + Component, View, OnInit, OnDestroy, OnChanges, Directive, LifecycleEvent, EventEmitter, ElementRef, Renderer, CORE_DIRECTIVES, FORM_DIRECTIVES, NgClass } from 'angular2/angular2'; +import {FileUploader} from './file-uploader'; + +// todo: filters + @Directive({ selector: '[ng2-file-select]', - properties: ['config: ng2FileSelect'] + properties: ['config: ng2FileSelect', 'uploader'], + host: { + '(change)': 'onChange()' + } }) export class FileSelect { + public uploader:FileUploader; public config:any = {}; - constructor(private element:ElementRef, private renderer:Renderer) { - console.log('it works!'); + constructor(private element:ElementRef) { + } + + public getOptions() { + return this.uploader.options; + } + + public getFilters() { + } + + public isEmptyAfterSelection():boolean { + return !!this.element.nativeElement.attributes.multiple; + } + + onChange() { + // let files = this.uploader.isHTML5 ? this.element.nativeElement[0].files : this.element.nativeElement[0]; + let files = this.element.nativeElement.files; + let options = this.getOptions(); + let filters = this.getFilters(); + + // if(!this.uploader.isHTML5) this.destroy(); + + this.uploader.addToQueue(files, options, filters); + if (this.isEmptyAfterSelection()) { + // todo + // this.element.nativeElement.properties.value = ''; + /*this.element.nativeElement + .replaceWith(this.element = this.element.nativeElement.clone(true)); // IE fix*/ + } } } diff --git a/components/file-upload/file-uploader.ts b/components/file-upload/file-uploader.ts new file mode 100644 index 0000000..dd0e0d9 --- /dev/null +++ b/components/file-upload/file-uploader.ts @@ -0,0 +1,404 @@ +import { + EventEmitter, ElementRef, Renderer +} from 'angular2/angular2'; + +import {FileLikeObject} from './file-like-object'; +import {FileItem} from './file-item'; + +function isFile(value) { + return (File && value instanceof File); +} + +function isFileLikeObject(value) { + return value instanceof FileLikeObject; +} + +export class FileUploader { + public url:string; + public isUploading:boolean = false; + public queue:Array = []; + public progress:number = 0; + public autoUpload:boolean = false; + public isHTML5:boolean = true; + public removeAfterUpload:boolean = false; + public queueLimit:number; + public _nextIndex = 0; + public filters:Array = []; + private _failFilterIndex:number; + + constructor(public options:any) { + // Object.assign(this, options); + this.url = options.url; + this.filters.unshift({name: 'queueLimit', fn: this._queueLimitFilter}); + this.filters.unshift({name: 'folder', fn: this._folderFilter}); + } + + public addToQueue(files, options, filters) { + let list = []; + for (let file of files) { + list.push(file); + } + + let arrayOfFilters = this._getFilters(filters); + let count = this.queue.length; + let addedFileItems = []; + + list.map(some => { + let temp = new FileLikeObject(some); + + if (this._isValidFile(temp, [], options)) { + let fileItem = new FileItem(this, some, options); + addedFileItems.push(fileItem); + this.queue.push(fileItem); + this._onAfterAddingFile(fileItem); + } else { + let filter = arrayOfFilters[this._failFilterIndex]; + this._onWhenAddingFileFailed(temp, filter, options); + } + }); + + if (this.queue.length !== count) { + this._onAfterAddingAll(addedFileItems); + this.progress = this._getTotalProgress(); + } + + this._render(); + + if (this.autoUpload) { + this.uploadAll(); + } + } + + public removeFromQueue(value) { + let index = this.getIndexOfItem(value); + let item = this.queue[index]; + if (item.isUploading) { + item.cancel(); + } + + this.queue.splice(index, 1); + item._destroy(); + this.progress = this._getTotalProgress(); + } + + public clearQueue() { + while (this.queue.length) { + this.queue[0].remove(); + } + + this.progress = 0; + } + + public uploadItem(value:FileItem) { + let index = this.getIndexOfItem(value); + let item = this.queue[index]; + let transport = this.isHTML5 ? '_xhrTransport' : '_iframeTransport'; + + item._prepareToUploading(); + if (this.isUploading) { + return; + } + + this.isUploading = true; + this[transport](item); + } + + public cancelItem(value) { + let index = this.getIndexOfItem(value); + let item = this.queue[index]; + let prop = this.isHTML5 ? '_xhr' : '_form'; + + if (item && item.isUploading) { + item[prop].abort(); + } + } + + public uploadAll() { + let items = this.getNotUploadedItems().filter(item => !item.isUploading); + if (!items.length) { + return; + } + + items.map(item => item._prepareToUploading()); + items[0].upload(); + } + + public cancelAll() { + let items = this.getNotUploadedItems(); + items.map(item => item.cancel()); + } + + + public isFile(value) { + return isFile(value); + } + + public isFileLikeObject(value) { + return value instanceof FileLikeObject; + } + + public getIndexOfItem(value) { + return typeof value === 'number' ? value : this.queue.indexOf(value); + } + + public getNotUploadedItems() { + return this.queue.filter(item => !item.isUploaded); + } + + public getReadyItems() { + return this.queue + .filter(item => (item.isReady && !item.isUploading)) + .sort((item1, item2) => item1.index - item2.index); + } + + public destroy() { + /*forEach(this._directives, (key) => { + forEach(this._directives[key], (object) => { + object.destroy(); + }); + });*/ + } + + public onAfterAddingAll(fileItems) { + } + + public onAfterAddingFile(fileItem) { + } + + public onWhenAddingFileFailed(item, filter, options) { + } + + public onBeforeUploadItem(fileItem) { + } + + public onProgressItem(fileItem, progress) { + } + + public onProgressAll(progress) { + } + + public onSuccessItem(item, response, status, headers) { + } + + public onErrorItem(item, response, status, headers) { + } + + public onCancelItem(item, response, status, headers) { + } + + public onCompleteItem(item, response, status, headers) { + } + + public onCompleteAll() { + } + + private _getTotalProgress(value = 0) { + if (this.removeAfterUpload) { + return value; + } + + let notUploaded = this.getNotUploadedItems().length; + let uploaded = notUploaded ? this.queue.length - notUploaded : this.queue.length; + let ratio = 100 / this.queue.length; + let current = value * ratio / 100; + + return Math.round(uploaded * ratio + current); + } + + private _getFilters(filters) { + if (!filters) { + return this.filters; + } + + if (Array.isArray(filters)) { + return filters; + } + + let names = filters.match(/[^\s,]+/g); + return this.filters + .filter(filter => names.indexOf(filter.name) !== -1); + } + + private _render() { + // todo: ? + } + + private _folderFilter(item) { + return !!(item.size || item.type); + } + + private _queueLimitFilter() { + return this.queue.length < this.queueLimit; + } + + private _isValidFile(file, filters, options) { + this._failFilterIndex = -1; + return !filters.length ? true : filters.every((filter) => { + this._failFilterIndex++; + return filter.fn.call(this, file, options); + }); + } + + private _isSuccessCode(status) { + return (status >= 200 && status < 300) || status === 304; + } + + private _transformResponse(response, headers):any { + // todo: ? + /*var headersGetter = this._headersGetter(headers); + forEach($http.defaults.transformResponse, (transformFn) => { + response = transformFn(response, headersGetter); + });*/ + return response; + } + + private _parseHeaders(headers) { + let parsed = {}, key, val, i; + + if (!headers) { + return parsed; + } + + headers.split('\n').map(line => { + i = line.indexOf(':'); + key = line.slice(0, i).trim().toLowerCase(); + val = line.slice(i + 1).trim(); + + if (key) { + parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; + } + }); + + return parsed; + } + + private _headersGetter(parsedHeaders) { + return (name) => { + if (name) { + return parsedHeaders[name.toLowerCase()] || null; + } + return parsedHeaders; + }; + } + + _xhrTransport(item:any) { + let xhr = item._xhr = new XMLHttpRequest(); + let form = new FormData(); + + this._onBeforeUploadItem(item); + + // todo + /*item.formData.map(obj => { + obj.map((value, key) => { + form.append(key, value); + }); + });*/ + + if (typeof item._file.size !== 'number') { + throw new TypeError('The file specified is no longer valid'); + } + + form.append(item.alias, item._file, item.file.name); + + xhr.upload.onprogress = (event) => { + let progress = Math.round(event.lengthComputable ? event.loaded * 100 / event.total : 0); + this._onProgressItem(item, progress); + }; + + xhr.onload = () => { + let headers = this._parseHeaders(xhr.getAllResponseHeaders()); + let response = this._transformResponse(xhr.response, headers); + let gist = this._isSuccessCode(xhr.status) ? 'Success' : 'Error'; + let method = '_on' + gist + 'Item'; + this[method](item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + }; + + xhr.onerror = () => { + let headers = this._parseHeaders(xhr.getAllResponseHeaders()); + let response = this._transformResponse(xhr.response, headers); + this._onErrorItem(item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + }; + + xhr.onabort = () => { + let headers = this._parseHeaders(xhr.getAllResponseHeaders()); + let response = this._transformResponse(xhr.response, headers); + this._onCancelItem(item, response, xhr.status, headers); + this._onCompleteItem(item, response, xhr.status, headers); + }; + + xhr.open(item.method, item.url, true); + xhr.withCredentials = item.withCredentials; + + // todo + /*item.headers.map((value, name) => { + xhr.setRequestHeader(name, value); + });*/ + + xhr.send(form); + this._render(); + } + + private _iframeTransport(item) { + // todo: implement it later + } + + private _onWhenAddingFileFailed(item, filter, options) { + this.onWhenAddingFileFailed(item, filter, options); + } + + private _onAfterAddingFile(item) { + this.onAfterAddingFile(item); + } + + private _onAfterAddingAll(items) { + this.onAfterAddingAll(items); + } + + private _onBeforeUploadItem(item) { + item._onBeforeUpload(); + this.onBeforeUploadItem(item); + } + + private _onProgressItem(item, progress) { + let total = this._getTotalProgress(progress); + this.progress = total; + item._onProgress(progress); + this.onProgressItem(item, progress); + this.onProgressAll(total); + this._render(); + } + + private _onSuccessItem(item, response, status, headers) { + item._onSuccess(response, status, headers); + this.onSuccessItem(item, response, status, headers); + } + + public _onErrorItem(item, response, status, headers) { + item._onError(response, status, headers); + this.onErrorItem(item, response, status, headers); + } + + private _onCancelItem(item, response, status, headers) { + item._onCancel(response, status, headers); + this.onCancelItem(item, response, status, headers); + } + + public _onCompleteItem(item, response, status, headers) { + item._onComplete(response, status, headers); + this.onCompleteItem(item, response, status, headers); + + let nextItem = this.getReadyItems()[0]; + this.isUploading = false; + + if (nextItem) { + nextItem.upload(); + return; + } + + this.onCompleteAll(); + this.progress = this._getTotalProgress(); + this._render(); + } +} diff --git a/components/index.ts b/components/index.ts index 226e78a..2209aa5 100644 --- a/components/index.ts +++ b/components/index.ts @@ -1,3 +1,5 @@ /// export * from './file-upload/file-select'; +export * from './file-upload/file-drop'; + diff --git a/demo/components/file-upload/simple-demo.html b/demo/components/file-upload/simple-demo.html index f9f6117..1196d5a 100644 --- a/demo/components/file-upload/simple-demo.html +++ b/demo/components/file-upload/simple-demo.html @@ -1,3 +1,118 @@ -

- -

+ + +
+ + + +
+ +
+ +

Select files

+ +
+ Base drop zone +
+ +
+ Another drop zone +
+ + Multiple +
+ + Single + +
+ +
+ +

Upload queue

+

Queue length: {{ uploader.queue.length }}

+ + + + + + + + + + + + + + + + + + + + +
NameSizeProgressStatusActions
{{ item.file.name }}{{ item.file.size/1024/1024 | number:'.2' }} MB +
+
+
+
+ + + + + + + +
+ +
+
+ Queue progress: +
+
+
+
+ + + +
+ +
+ +
+ +
\ No newline at end of file diff --git a/demo/components/file-upload/simple-demo.ts b/demo/components/file-upload/simple-demo.ts index e48dc87..1e9a58c 100644 --- a/demo/components/file-upload/simple-demo.ts +++ b/demo/components/file-upload/simple-demo.ts @@ -2,10 +2,11 @@ import { Component, View, EventEmitter, - CORE_DIRECTIVES, FORM_DIRECTIVES, NgClass + CORE_DIRECTIVES, FORM_DIRECTIVES, NgClass, NgStyle } from 'angular2/angular2'; -import {fileUpload} from '../../../components/index'; +import {FileSelect, FileDrop} from '../../../components/index'; +import {FileUploader} from '../../../components/file-upload/file-uploader'; // webpack html imports let template = require('./simple-demo.html'); @@ -15,7 +16,18 @@ let template = require('./simple-demo.html'); }) @View({ template: template, - directives: [fileUpload, NgClass, CORE_DIRECTIVES, FORM_DIRECTIVES] + directives: [FileSelect, FileDrop, NgClass, NgStyle, CORE_DIRECTIVES, FORM_DIRECTIVES] }) export class SimpleDemo { + private uploader:FileUploader = new FileUploader({url: '/api/'}); + private hasBaseDropZoneOver:boolean = false; + private hasAnotherDropZoneOver:boolean = false; + + private fileOverBase(e:any) { + this.hasBaseDropZoneOver = e; + } + + private fileOverAnother(e:any) { + this.hasAnotherDropZoneOver = e; + } } diff --git a/tsconfig.json b/tsconfig.json index 27d6949..76c9bb0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,10 @@ ], "files": [ "./components/file-upload/file-select.ts", + "./components/file-upload/file-item.ts", + "./components/file-upload/file-like-object.ts", + "./components/file-upload/file-drop.ts", + "./components/file-upload/file-uploader.ts", "./components/index.ts", "./components/module.ts", "./demo/components/file-upload-section.ts", diff --git a/webpack.config.js b/webpack.config.js index 1bb9abe..9f33710 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -64,6 +64,15 @@ var config = { inline: true, colors: true, historyApiFallback: true, + proxy: { + '*/api/*': 'http://localhost:3000/' + }, + + /*noInfo: false, + hot: true, + inline: true, + devtool: 'eval',*/ + contentBase: src, publicPath: dest },