app/javascript/js/libs/jquery.html5-fileupload.js

Summary

Maintainability
F
3 days
Test Coverage
/*
 *  jQuery HTML5 File Upload
 *
 *  Author: timdream at gmail.com
 *  Web: http://timc.idv.tw/html5-file-upload/
 *
 *  Ajax File Upload that use real xhr,
 *  built with getAsBinary, sendAsBinary, FormData, FileReader, ArrayBuffer, BlobBuilder and etc.
 *  works in Firefox 3, Chrome 5, Safari 5 and higher
 *
 *  Image resizing and uploading currently works in Fx 3 and up, and Chrome 9 (dev) and up only.
 *  Extra settings will allow current Webkit users to upload the original image
 *  or send the resized image in base64 form.
 *
 *  Usage:
 *   $.fileUploadSupported // a boolean value indicates if the browser is supported.
 *   $.imageUploadSupported // a boolean value indicates if the browser could resize image and upload in binary form.
 *   $.fileUploadAsBase64Supported // a boolean value indicate if the browser upload files in based64.
 *   $.imageUploadAsBase64Supported // a boolean value indicate if the browser could resize image and upload in based64.
 *   $('input[type=file]').fileUpload(ajaxSettings); //Make a input[type=file] select-and-send file upload widget
 *   $('#any-element').fileUpload(ajaxSettings); //Make a element receive dropped file
 *   //TBD $('form#fileupload').fileUpload(ajaxSettings); //Send a ajax form with file
 *   //TBD $('canvas').fileUpload(ajaxSettings); //Upload given canvas as if it's an png image.
 *
 *   ajaxSettings is the object contains $.ajax settings that will be passed to.
 *   Available extended settings are:
 *      fileType:
 *           regexp check against filename extension; You should always checked it again on server-side.
 *           e.g. /^(gif|jpe?g|png|tiff?)$/i for images
 *      fileMaxSize:
 *           Maxium file size allowed in bytes. Use scientific notation for converience.
 *           e.g. 1E4 for 1KB, 1E8 for 1MB, 1E9 for 10MB.
 *             If you really care the difference between 1024 and 1000, use Math.pow(2, 10)
 *      fileError(info, textStatus, textDescription):
 *           callback function when there is any error preventing file upload to start,
 *           $.ajax and ajax events won't be called when error.
 *           Use $.noop to overwrite default alert function.
 *      imageMaxWidth, imageMaxHeight:
 *           Use any of the two settings to enable client-size image resizing.
 *           Image will be resized to fit into given rectangle.
 *           File size and type limit checking will be ignored.
 *      allowUploadOriginalImage:
 *           Set to true if you accept original image to be uploaded as a fallback
 *           when image resizing functionality is not availible (such as Webkit browsers).
 *           File size and type limit will be enforced.
 *      allowDataInBase64:
 *           Alternatively, you may wish to resize the image anyway and send the data
 *           in base64. The data will be 133% larger and you will need to process it further with
 *           server-side script.
 *           This setting might work with browsers which could read file but cannot send it in original
 *           binary (no known browser are designed this way though)
 *      forceResize:
 *           Set to true will cause the image being re-sampled even if the resized image
 *           has the same demension as the original one.
 *      imageType:
 *           Acceptable values are: 'jpeg', 'png', or 'auto'.
 *
 *  TBD:
 *   ability to change settings after binding (you can unbind and bind again as a workaround)
 *   multipole file handling
 *   form intergation
 *
 */

(function($) {
    // Don't do logging if window.log function does not exist.
    var log = window.console.log || $.noop;

    // jQuery.ajax config
    var config = {
        fileError: function (info, textStatus, textDescription) {
            window.alert(textDescription);
        }
    };

    // Feature detection

    // Read as binary string: FileReader API || Gecko-specific function (Fx3)
    var canReadAsBinaryString = (window.FileReader || window.File.prototype.getAsBinary);
    // Read file using FormData interface
    var canReadFormData = !!(window.FormData);
    // Read file into data: URL: FileReader API || Gecko-specific function (Fx3)
    var canReadAsBase64 = (window.FileReader || window.File.prototype.getAsDataURL);

    var canResizeImageToBase64 = !!(document.createElement('canvas').toDataURL);
    var canResizeImageToBinaryString = canResizeImageToBase64 && window.atob;
    var canResizeImageToFile = !!(document.createElement('canvas').mozGetAsFile);

    // Send file in multipart/form-data with binary xhr (Gecko-specific function)
    // || xhr.send(blob) that sends blob made with ArrayBuffer.
    var canSendBinaryString = (
        (window.XMLHttpRequest && window.XMLHttpRequest.prototype.sendAsBinary)
        || (window.ArrayBuffer && window.BlobBuilder)
    );
    // Send file as in FormData object
    var canSendFormData = !!(window.FormData);
    // Send image base64 data by extracting data: URL
    var canSendImageInBase64 = !!(document.createElement('canvas').toDataURL);

    var isSupported = (
        (canReadAsBinaryString && canSendBinaryString)
        || (canReadFormData && canSendFormData)
    );
    var isImageSupported = (
        canReadAsBase64 && (
            (canResizeImageToBinaryString && canSendBinaryString)
            || (canResizeImageToFile && canSendFormData)
        )
    );
    var isSupportedInBase64 = canReadAsBase64;
    var isImageSupportedInBase64 = canReadAsBase64 && canResizeImageToBase64;

    var dataURLtoBase64 = function (dataurl) {
        return dataurl.substring(dataurl.indexOf(',')+1, dataurl.length);
    }

    // Step 1: check file info and attempt to read the file
    // paramaters: Ajax settings, File object
    var handleFile = function (settings, file) {
        var info = {
            // properties of standard File object || Gecko 1.9 properties
            type: file.type || '', // MIME type
            size: file.size || file.fileSize,
            name: file.name || file.fileName
        };

        settings.resizeImage = !!(settings.imageMaxWidth || settings.imageMaxHeight);

        if (settings.resizeImage && !isImageSupported && settings.allowUploadOriginalImage) {
            log('WARN: Fall back to upload original un-resized image.');
            settings.resizeImage = false;
        }

        if (settings.resizeImage) {
            settings.imageMaxWidth = settings.imageMaxWidth || Infinity;
            settings.imageMaxHeight = settings.imageMaxHeight || Infinity;
        }

        if (!settings.resizeImage) {
            if (settings.fileType && settings.fileType.test) {
                // Not using MIME types
                if (!settings.fileType.test(info.name.substr(info.name.lastIndexOf('.')+1))) {
                    log('ERROR: Invalid Filetype.');
                    settings.fileError.call(this, info, 'INVALID_FILETYPE', 'Invalid filetype.');
                    return;
                }
            }

            if (settings.fileMaxSize && file.size > settings.fileMaxSize) {
                log('ERROR: File exceeds size limit.');
                settings.fileError.call(this, info, 'FILE_EXCEEDS_SIZE_LIMIT', 'File exceeds size limit.');
                return;
            }
        }

        if (!settings.resizeImage && canReadFormData) {
            log('INFO: Bypass file reading, insert file object into FormData object directly.');
            handleForm(settings, 'file', file, info);
        } else if (window.FileReader) {
            log('INFO: Using FileReader to do asynchronously file reading.');
            var reader = new FileReader();
            reader.onerror = function (ev) {
                if (ev.target.error) {
                    switch (ev.target.error) {
                        case 8:
                        log('ERROR: File not found.');
                        settings.fileError.call(this, info, 'FILE_NOT_FOUND', 'File not found.');
                        break;
                        case 24:
                        log('ERROR: File not readable.');
                        settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.');
                        break;
                        case 18:
                        log('ERROR: File cannot be access due to security constrant.');
                        settings.fileError.call(this, info, 'SECURITY_ERROR', 'File cannot be access due to security constrant.');
                        break;
                        case 20: //User Abort
                        break;
                    }
                }
            }
            if (!settings.resizeImage) {
                if (canSendBinaryString) {
                    reader.onloadend = function (ev) {
                        var bin = ev.target.result;
                        handleForm(settings, 'bin', bin, info);
                    };
                    reader.readAsBinaryString(file);
                } else if (settings.allowDataInBase64) {
                    reader.onloadend = function (ev) {
                        handleForm(
                            settings,
                            'base64',
                            dataURLtoBase64(ev.target.result),
                            info
                        );
                    };
                    reader.readAsDataURL(file);
                } else {
                    log('ERROR: No available method to extract file; allowDataInBase64 not set.');
                    settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.');
                }
            } else {
                reader.onloadend = function (ev) {
                    var dataurl = ev.target.result;
                    handleImage(settings, dataurl, info);
                };
                reader.readAsDataURL(file);
            }
        } else if (window.File.prototype.getAsBinary) {
            log('WARN: FileReader does not exist, UI will be blocked when reading big file.');
            if (!settings.resizeImage) {
                try {
                    var bin = file.getAsBinary();
                } catch (e) {
                    log('ERROR: File not readable.');
                    settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.');
                    return;
                }
                handleForm(settings, 'bin', bin, info);
            } else {
                try {
                    var bin = file.getAsDataURL();
                } catch (e) {
                    log('ERROR: File not readable.');
                    settings.fileError.call(this, info, 'IO_ERROR', 'File not readable.');
                    return;
                }
                handleImage(settings, dataurl, info);
            }
        } else {
            log('ERROR: No available method to extract file; this browser is not supported.');
            settings.fileError.call(this, info, 'NOT_SUPPORT', 'ERROR: No available method to extract file; this browser is not supported.');
        }
    };

    // step 1.5: inject file into <img>, paste the pixels into <canvas>,
    // read the final image
    var handleImage = function (settings, dataurl, info) {
        var img = new Image();
        img.onerror = function () {
            log('ERROR: <img> failed to load, file is not a supported image format.');
            settings.fileError.call(this, info, 'FILE_NOT_IMAGE', 'File is not a supported image format.');
        };
        img.onload = function () {
            var ratio = Math.max(
                img.width/settings.imageMaxWidth,
                img.height/settings.imageMaxHeight,
                1
            );
            var d = {
                w: Math.floor(Math.max(img.width/ratio, 1)),
                h: Math.floor(Math.max(img.height/ratio, 1))
            }
            log(
                'INFO: Original image size: ' + img.width.toString(10) + 'x' + img.height.toString(10)
                + ', resized image size: ' + d.w + 'x' + d.h + '.'
            );
            if (!settings.forceResize && img.width === d.w && img.height === d.h) {
                log('INFO: Image demension is the same, send the original file.');
                if (canResizeImageToBinaryString) {
                    handleForm(
                        settings,
                        'bin',
                        window.atob(dataURLtoBase64(dataurl)),
                        info
                    );
                } else if (settings.allowDataInBase64) {
                    handleForm(
                        settings,
                        'base64',
                        dataURLtoBase64(dataurl),
                        info
                    );
                } else {
                    log('ERROR: No available method to send the original file; allowDataInBase64 not set.');
                    settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.');
                }
                return;
            }
            var canvas = document.createElement('canvas');
            canvas.setAttribute('width', d.w);
            canvas.setAttribute('height', d.h);
            canvas.getContext('2d').drawImage(
                img,
                0,
                0,
                img.width,
                img.height,
                0,
                0,
                d.w,
                d.h
            );
            if (!settings.imageType || settings.imageType === 'auto') {
                if (info.type === 'image/jpeg') settings.imageType = 'jpeg';
                else settings.imageType = 'png';
            }

            var ninfo = {
                type: 'image/' + settings.imageType,
                name: info.name.substr(0, info.name.indexOf('.')) + '.resized.' + settings.imageType
            };

            if (canResizeImageToFile && canSendFormData) {
                // Gecko 2 (Fx4) non-standard function
                var nfile = canvas.mozGetAsFile(
                    ninfo.name,
                    'image/' + settings.imageType
                );
                ninfo.size = file.size || file.fileSize;
                handleForm(
                    settings,
                    'file',
                    nfile,
                    ninfo
                );
            } else if (canResizeImageToBinaryString && canSendBinaryString) {
                // Read the image as DataURL, convert it back to binary string.
                var bin = window.atob(dataURLtoBase64(canvas.toDataURL('image/' + settings.imageType)));
                ninfo.size = bin.length;
                handleForm(
                    settings,
                    'bin',
                    bin,
                    ninfo
                );
            } else if (settings.allowDataInBase64 && canResizeImageToBase64 && canSendImageInBase64) {
                handleForm(
                    settings,
                    'base64',
                    dataURLtoBase64(canvas.toDataURL('image/' + settings.imageType)),
                    ninfo
                );
            } else {
                log('ERROR: No available method to extract image; allowDataInBase64 not set.');
                settings.fileError.call(this, info, 'NO_BIN_SUPPORT_AND_BASE64_NOT_SET', 'No available method to extract file; allowDataInBase64 not set.');
            }
        }
        img.src = dataurl;
    }
    // Step 2: construct form data and send the file
    // paramaters: Ajax settings, File object, binary string of file || null, file info assoc array
    var handleForm = function (settings, type, data, info) {
        if (canSendFormData && type === 'file') {
            // FormData API saves the day
            log('INFO: Using FormData to construct form.');
            var formdata = new FormData();
            formdata.append('Filedata', data);
            // Prevent jQuery form convert FormData object into string.
            settings.processData = false;
            // Prevent jQuery from overwrite automatically generated xhr content-Type header
            // by unsetting the default contentType and inject data only right before xhr.send()
            settings.contentType = null;
            settings.__beforeSend = settings.beforeSend;
            settings.beforeSend = function (xhr, s) {
                s.data = formdata;
                if (s.__beforeSend) return s.__beforeSend.call(this, xhr, s);
            }
            //settings.data = formdata;
        } else if (canSendBinaryString && type === 'bin') {
            log('INFO: Concat our own multipart/form-data data string.');

            // A placeholder MIME type
            if (!info.type) info.type = 'application/octet-stream';

            if (/[^\x20-\x7E]/.test(info.name)) {
                log('INFO: Filename contains non-ASCII code, do UTF8-binary string conversion.');
                info.name_bin = unescape(encodeURIComponent(info.name));
            }

            //filtered out non-ASCII chars in filenames
            // info.name = info.name.replace(/[^\x20-\x7E]/g, '_');

            // multipart/form-data boundary
            var bd = 'xhrupload-' + parseInt(Math.random()*(2 << 16));
            settings.contentType = 'multipart/form-data; boundary=' + bd;
            var formdata = '--' + bd + '\n' // RFC 1867 Format, simulate form file upload
            + 'content-disposition: form-data; name="Filedata";'
            + ' filename="' + (info.name_bin || info.name) + '"\n'
            + 'Content-Type: ' + info.type + '\n\n'
            + data + '\n\n'
            + '--' + bd + '--';

            if (window.XMLHttpRequest.prototype.sendAsBinary) {
                // Use xhr.sendAsBinary that takes binary string
                log('INFO: Pass binary string to xhr.');
                settings.data = formdata;
            } else {
                // make a blob
                log('INFO: Convert binary string into Blob.');
                var buf = new ArrayBuffer(formdata.length);
                var view = new Uint8Array(buf);
                $.each(
                    formdata,
                    function (i, o) {
                        view[i] = o.charCodeAt(0);
                    }
                );
                var bb = new BlobBuilder();
                bb.append(buf);
                var blob = bb.getBlob();

                settings.processData = false;
                settings.__beforeSend = settings.beforeSend;
                settings.beforeSend = function (xhr, s) {
                    s.data = blob;
                    if (s.__beforeSend) return s.__beforeSend.call(this, xhr, s);
                };
            }

        } else if (settings.allowDataInBase64 && type === 'base64') {
            log('INFO: Concat our own multipart/form-data data string; send the file in base64 because binary xhr is not supported.');

            // A placeholder MIME type
            if (!info.type) info.type = 'application/octet-stream';

            // multipart/form-data boundary
            var bd = 'xhrupload-' + parseInt(Math.random()*(2 << 16));
            settings.contentType = 'multipart/form-data; boundary=' + bd;
            settings.data = '--' + bd + '\n' // RFC 1867 Format, simulate form file upload
            + 'content-disposition: form-data; name="Filedata";'
            + ' filename="' + encodeURIComponent(info.name) + '.base64"\n'
            + 'Content-Transfer-Encoding: base64\n' // Vaild MIME header, but won't work with PHP file upload handling.
            + 'Content-Type: ' + info.type + '\n\n'
            + data + '\n\n'
            + '--' + bd + '--';
        } else {
            log('ERROR: Data is not given in processable form.');
            settings.fileError.call(this, info, 'INTERNAL_ERROR', 'Data is not given in processable form.');
            return;
        }
        xhrupload(settings);
    };

    // Step 3: start sending out file
    var xhrupload = function (settings) {
        log('INFO: Sending file.');
        if (typeof settings.data === 'string' && canSendBinaryString) {
            log('INFO: Using xhr.sendAsBinary.');
            settings.___beforeSend = settings.beforeSend;
            settings.beforeSend = function (xhr, s) {
                xhr.send = xhr.sendAsBinary;
                if (s.___beforeSend) return s.___beforeSend.call(this, xhr, s);
            }
        }
        $.ajax(settings);
    };

    $.fn.fileUpload = function(settings) {
        this.each(function(i, el) {
            if ($(el).is('input[type=file]')) {
                log('INFO: binding onchange event to a input[type=file].');
                $(el).bind(
                    'change',
                    function () {
                        if (!this.files.length) {
                            log('ERROR: no file selected.');
                            return;
                        } else if (this.files.length > 1) {
                            log('WARN: Multiple file upload not implemented yet, only first file will be uploaded.');
                        }
                        handleFile($.extend({}, config, settings), this.files[0]);

                        if (this.form.length === 1) {
                            this.form.reset();
                        } else {
                            log('WARN: Unable to reset file selection, upload won\'t be triggered again if user selects the same file.');
                        }
                        return;
                    }
                );
            }

            if ($(el).is('form')) {
                log('ERROR: <form> not implemented yet.');
            } else {
                log('INFO: binding ondrop event.');
                $(el).bind(
                    'dragover', // dragover behavior should be blocked for drop to invoke.
                    function(ev) {
                        return false;
                    }
                ).bind(
                    'drop',
                    function (ev) {
                        if (!ev.originalEvent.dataTransfer.files) {
                            log('ERROR: No FileList object present; user might had dropped text.');
                            return false;
                        }
                        if (!ev.originalEvent.dataTransfer.files.length) {
                            log('ERROR: User had dropped a virual file (e.g. "My Computer")');
                            return false;
                        }
                        if (!ev.originalEvent.dataTransfer.files.length > 1) {
                            log('WARN: Multiple file upload not implemented yet, only first file will be uploaded.');
                        }
                        handleFile($.extend({}, config, settings), ev.originalEvent.dataTransfer.files[0]);
                        return false;
                    }
                );
            }
        });

        return this;
    };

    $.fileUploadSupported = isSupported;
    $.imageUploadSupported = isImageSupported;
    $.fileUploadAsBase64Supported = isSupportedInBase64;
    $.imageUploadAsBase64Supported = isImageSupportedInBase64;

})(jQuery);