mirror of
https://github.com/rstudio/shiny.git
synced 2026-02-08 21:55:02 -05:00
474 lines
15 KiB
JavaScript
474 lines
15 KiB
JavaScript
var IE8FileUploader = function(shinyapp, id, fileEl) {
|
|
this.shinyapp = shinyapp;
|
|
this.id = id;
|
|
this.fileEl = fileEl;
|
|
this.beginUpload();
|
|
};
|
|
(function() {
|
|
this.beginUpload = function() {
|
|
var self = this;
|
|
// Create invisible frame
|
|
var iframeId = 'shinyupload_iframe_' + this.id;
|
|
this.iframe = document.createElement('iframe');
|
|
this.iframe.id = iframeId;
|
|
this.iframe.name = iframeId;
|
|
this.iframe.setAttribute('style', 'position: fixed; top: 0; left: 0; width: 0; height: 0; border: none');
|
|
$('body').append(this.iframe);
|
|
var iframeDestroy = function() {
|
|
// Forces Shiny to flushReact, flush outputs, etc. Without this we get
|
|
// invalidated reactives, but observers don't actually execute.
|
|
self.shinyapp.makeRequest('uploadieFinish', [], function(){}, function(){});
|
|
$(self.iframe).remove();
|
|
// Reset the file input's value to "". This allows the same file to be
|
|
// uploaded again. https://stackoverflow.com/a/22521275
|
|
$(self.fileEl).val("");
|
|
};
|
|
if (this.iframe.attachEvent) {
|
|
this.iframe.attachEvent('onload', iframeDestroy);
|
|
} else {
|
|
this.iframe.onload = iframeDestroy;
|
|
}
|
|
|
|
this.form = document.createElement('form');
|
|
this.form.method = 'POST';
|
|
this.form.setAttribute('enctype', 'multipart/form-data');
|
|
this.form.action = "session/" + encodeURI(this.shinyapp.config.sessionId) +
|
|
"/uploadie/" + encodeURI(this.id);
|
|
this.form.id = 'shinyupload_form_' + this.id;
|
|
this.form.target = iframeId;
|
|
$(this.form).insertAfter(this.fileEl).append(this.fileEl);
|
|
this.form.submit();
|
|
};
|
|
}).call(IE8FileUploader.prototype);
|
|
|
|
var FileUploader = function(shinyapp, id, files, el) {
|
|
this.shinyapp = shinyapp;
|
|
this.id = id;
|
|
this.el = el;
|
|
FileProcessor.call(this, files);
|
|
};
|
|
$.extend(FileUploader.prototype, FileProcessor.prototype);
|
|
(function() {
|
|
this.makeRequest = function(method, args, onSuccess, onFailure, blobs) {
|
|
this.shinyapp.makeRequest(method, args, onSuccess, onFailure, blobs);
|
|
};
|
|
this.onBegin = function(files, cont) {
|
|
var self = this;
|
|
|
|
// Reset progress bar
|
|
this.$setError(null);
|
|
this.$setActive(true);
|
|
this.$setVisible(true);
|
|
this.onProgress(null, 0);
|
|
|
|
this.totalBytes = 0;
|
|
this.progressBytes = 0;
|
|
$.each(files, function(i, file) {
|
|
self.totalBytes += file.size;
|
|
});
|
|
|
|
var fileInfo = $.map(files, function(file, i) {
|
|
return {
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type
|
|
};
|
|
});
|
|
|
|
this.makeRequest(
|
|
'uploadInit', [fileInfo],
|
|
function(response) {
|
|
self.jobId = response.jobId;
|
|
self.uploadUrl = response.uploadUrl;
|
|
cont();
|
|
},
|
|
function(error) {
|
|
self.onError(error);
|
|
});
|
|
};
|
|
this.onFile = function(file, cont) {
|
|
var self = this;
|
|
this.onProgress(file, 0);
|
|
|
|
$.ajax(this.uploadUrl, {
|
|
type: 'POST',
|
|
cache: false,
|
|
xhr: function() {
|
|
var xhrVal = $.ajaxSettings.xhr();
|
|
if (xhrVal.upload) {
|
|
xhrVal.upload.onprogress = function(e) {
|
|
if (e.lengthComputable) {
|
|
self.onProgress(
|
|
file,
|
|
(self.progressBytes + e.loaded) / self.totalBytes);
|
|
}
|
|
};
|
|
}
|
|
return xhrVal;
|
|
},
|
|
data: file,
|
|
contentType: 'application/octet-stream',
|
|
processData: false,
|
|
success: function() {
|
|
self.progressBytes += file.size;
|
|
cont();
|
|
},
|
|
error: function(jqXHR, textStatus, errorThrown) {
|
|
self.onError(jqXHR.responseText || textStatus);
|
|
}
|
|
});
|
|
};
|
|
this.onComplete = function() {
|
|
var self = this;
|
|
|
|
var fileInfo = $.map(this.files, function(file, i) {
|
|
return {
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type
|
|
};
|
|
});
|
|
|
|
// Trigger shiny:inputchanged. Unlike a normal shiny:inputchanged event,
|
|
// it's not possible to modify the information before the values get
|
|
// sent to the server.
|
|
var evt = jQuery.Event("shiny:inputchanged");
|
|
evt.name = this.id;
|
|
evt.value = fileInfo;
|
|
evt.binding = fileInputBinding;
|
|
evt.el = this.el;
|
|
evt.inputType = 'shiny.fileupload';
|
|
$(document).trigger(evt);
|
|
|
|
this.makeRequest(
|
|
'uploadEnd', [this.jobId, this.id],
|
|
function(response) {
|
|
self.$setActive(false);
|
|
self.onProgress(null, 1);
|
|
self.$bar().text('Upload complete');
|
|
// Reset the file input's value to "". This allows the same file to be
|
|
// uploaded again. https://stackoverflow.com/a/22521275
|
|
$(evt.el).val("");
|
|
},
|
|
function(error) {
|
|
self.onError(error);
|
|
});
|
|
this.$bar().text('Finishing upload');
|
|
};
|
|
this.onError = function(message) {
|
|
this.$setError(message || '');
|
|
this.$setActive(false);
|
|
};
|
|
this.onAbort = function() {
|
|
this.$setVisible(false);
|
|
};
|
|
this.onProgress = function(file, completed) {
|
|
this.$bar().width(Math.round(completed*100) + '%');
|
|
this.$bar().text(file ? file.name : '');
|
|
};
|
|
this.$container = function() {
|
|
return $('#' + $escape(this.id) + '_progress.shiny-file-input-progress');
|
|
};
|
|
this.$bar = function() {
|
|
return $('#' + $escape(this.id) + '_progress.shiny-file-input-progress .progress-bar');
|
|
};
|
|
this.$setVisible = function(visible) {
|
|
this.$container().css('visibility', visible ? 'visible' : 'hidden');
|
|
};
|
|
this.$setError = function(error) {
|
|
this.$bar().toggleClass('progress-bar-danger', (error !== null));
|
|
if (error !== null) {
|
|
this.onProgress(null, 1);
|
|
this.$bar().text(error);
|
|
}
|
|
};
|
|
this.$setActive = function(active) {
|
|
this.$container().toggleClass('active', !!active);
|
|
};
|
|
}).call(FileUploader.prototype);
|
|
|
|
|
|
// NOTE On Safari, at least version 10.1.2, *if the developer console is open*,
|
|
// setting the input's value will behave strangely because of a Safari bug. The
|
|
// uploaded file's name will appear over the placeholder value, instead of
|
|
// replacing it. The workaround is to restart Safari. When I (Alan Dipert) ran
|
|
// into this bug Winston Chang helped me diagnose the exact problem, and Winston
|
|
// then submitted a bug report to Apple.
|
|
function setFileText($el, files) {
|
|
var $fileText = $el.closest('div.input-group').find('input[type=text]');
|
|
if (files.length === 1) {
|
|
$fileText.val(files[0].name);
|
|
} else {
|
|
$fileText.val(files.length + " files");
|
|
}
|
|
}
|
|
|
|
// If previously selected files are uploading, abort that.
|
|
function abortCurrentUpload($el) {
|
|
var uploader = $el.data('currentUploader');
|
|
if (uploader) uploader.abort();
|
|
// Clear data-restore attribute if present.
|
|
$el.removeAttr('data-restore');
|
|
}
|
|
|
|
function uploadDroppedFilesIE10Plus(el, files) {
|
|
var $el = $(el);
|
|
abortCurrentUpload($el);
|
|
|
|
// Set the label in the text box
|
|
setFileText($el, files);
|
|
|
|
// Start the new upload and put the uploader in 'currentUploader'.
|
|
$el.data('currentUploader',
|
|
new FileUploader(exports.shinyapp,
|
|
fileInputBinding.getId(el),
|
|
files,
|
|
el));
|
|
}
|
|
|
|
function uploadFiles(evt) {
|
|
var $el = $(evt.target);
|
|
abortCurrentUpload($el);
|
|
|
|
var files = evt.target.files;
|
|
// IE8 here does not necessarily mean literally IE8; it indicates if the web
|
|
// browser supports the FileList object (IE8/9 do not support it)
|
|
var IE8 = typeof(files) === 'undefined';
|
|
var id = fileInputBinding.getId(evt.target);
|
|
|
|
if (!IE8 && files.length === 0)
|
|
return;
|
|
|
|
// Set the label in the text box
|
|
var $fileText = $el.closest('div.input-group').find('input[type=text]');
|
|
if (IE8) {
|
|
// If we're using IE8/9, just use this placeholder
|
|
$fileText.val("[Uploaded file]");
|
|
} else {
|
|
setFileText($el, files);
|
|
}
|
|
|
|
// Start the new upload and put the uploader in 'currentUploader'.
|
|
if (IE8) {
|
|
/*jshint nonew:false */
|
|
new IE8FileUploader(exports.shinyapp, id, evt.target);
|
|
} else {
|
|
$el.data('currentUploader',
|
|
new FileUploader(exports.shinyapp, id, files, evt.target));
|
|
}
|
|
}
|
|
|
|
// Here we maintain a list of all the current file inputs. This is necessary
|
|
// because we need to trigger events on them in order to respond to file drag
|
|
// events. For example, they should all light up when a file is dragged on to
|
|
// the page.
|
|
var $fileInputs = $();
|
|
|
|
var fileInputBinding = new InputBinding();
|
|
$.extend(fileInputBinding, {
|
|
find: function(scope) {
|
|
return $(scope).find('input[type="file"]');
|
|
},
|
|
getId: function(el) {
|
|
return InputBinding.prototype.getId.call(this, el) || el.name;
|
|
},
|
|
getValue: function(el) {
|
|
// This returns a non-undefined value only when there's a 'data-restore'
|
|
// attribute, which is set only when restoring Shiny state. If a file is
|
|
// uploaded through the browser, 'data-restore' gets cleared.
|
|
var data = $(el).attr('data-restore');
|
|
if (data) {
|
|
data = JSON.parse(data);
|
|
|
|
// Set the label in the text box
|
|
var $fileText = $(el).closest('div.input-group').find('input[type=text]');
|
|
if (data.name.length === 1) {
|
|
$fileText.val(data.name[0]);
|
|
} else {
|
|
$fileText.val(data.name.length + " files");
|
|
}
|
|
|
|
// Manually set up progress bar. A bit inelegant because it duplicates
|
|
// code from FileUploader, but duplication is less bad than alternatives.
|
|
var $progress = $(el).closest('div.form-group').find('.progress');
|
|
var $bar = $progress.find('.progress-bar');
|
|
$progress.removeClass('active');
|
|
$bar.width('100%');
|
|
$bar.css('visibility', 'visible');
|
|
|
|
return data;
|
|
|
|
} else {
|
|
return null;
|
|
}
|
|
},
|
|
setValue: function(el, value) {
|
|
// Not implemented
|
|
},
|
|
getType: function(el) {
|
|
// This will be used only when restoring a file from a saved state.
|
|
return 'shiny.file';
|
|
},
|
|
_zoneOf: function(el) {
|
|
return $(el).closest("div.input-group");
|
|
},
|
|
// This function makes it possible to attach listeners to the dragenter,
|
|
// dragleave, and drop events of a single element with children. It's not
|
|
// intuitive to do directly because outer elements fire "dragleave" events
|
|
// both when the drag leaves the element and when the drag enters a child. To
|
|
// make it easier, we maintain a count of the elements being dragged across
|
|
// and trigger 3 new types of event:
|
|
//
|
|
// 1. draghover:enter - When a drag enters el and any of its children.
|
|
// 2. draghover:leave - When the drag leaves el and all of its children.
|
|
// 3. draghover:drop - When an item is dropped on el or any of its children.
|
|
_enableDraghover: function(el) {
|
|
let $el = $(el),
|
|
childCounter = 0;
|
|
$el.on({
|
|
"dragenter.draghover": e => {
|
|
if (childCounter++ === 0) {
|
|
$el.trigger("draghover:enter", e);
|
|
}
|
|
},
|
|
"dragleave.draghover": e => {
|
|
if (--childCounter === 0) {
|
|
$el.trigger("draghover:leave", e);
|
|
}
|
|
if (childCounter < 0) {
|
|
console.error("draghover childCounter is negative somehow");
|
|
}
|
|
},
|
|
"dragover.draghover": e => {
|
|
e.preventDefault();
|
|
},
|
|
"drop.draghover": e => {
|
|
childCounter = 0;
|
|
$el.trigger("draghover:drop", e);
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
return $el;
|
|
},
|
|
_disableDraghover: function(el) {
|
|
return $(el).off(".draghover");
|
|
},
|
|
_ZoneClass: {
|
|
ACTIVE: "shiny-file-input-active",
|
|
OVER: "shiny-file-input-over"
|
|
},
|
|
_enableDocumentEvents: function() {
|
|
let $doc = $("html"),
|
|
{ACTIVE, OVER} = this._ZoneClass;
|
|
this._enableDraghover($doc)
|
|
.on({
|
|
"draghover:enter.draghover": e => {
|
|
this._zoneOf($fileInputs).addClass(ACTIVE);
|
|
},
|
|
"draghover:leave.draghover": e => {
|
|
this._zoneOf($fileInputs).removeClass(ACTIVE);
|
|
},
|
|
"draghover:drop.draghover": e => {
|
|
this._zoneOf($fileInputs)
|
|
.removeClass(OVER)
|
|
.removeClass(ACTIVE);
|
|
}
|
|
});
|
|
},
|
|
_disableDocumentEvents: function() {
|
|
let $doc = $("html");
|
|
$doc.off(".draghover");
|
|
this._disableDraghover($doc);
|
|
},
|
|
_canSetFiles: function(fileList) {
|
|
var testEl = document.createElement("input");
|
|
testEl.type = "file";
|
|
try {
|
|
testEl.files = fileList;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
_handleDrop: function(e, el) {
|
|
const files = e.originalEvent.dataTransfer.files,
|
|
$el = $(el);
|
|
if (files === undefined || files === null) {
|
|
// 1. The FileList object isn't supported by this browser, and
|
|
// there's nothing else we can try. (< IE 10)
|
|
console.log("Dropping files is not supported on this browser. (no FileList)");
|
|
} else if (!this._canSetFiles(files)) {
|
|
// 2. The browser doesn't support assigning a type=file input's .files
|
|
// property, but we do have a FileList to work with. (IE10+/Edge)
|
|
$el.val("");
|
|
uploadDroppedFilesIE10Plus(el, files);
|
|
} else {
|
|
// 3. The browser supports FileList and input.files assignment.
|
|
// (Chrome, Safari)
|
|
$el.val("");
|
|
el.files = e.originalEvent.dataTransfer.files;
|
|
// Recent versions of Firefox (57+, or "Quantum" and beyond) don't seem to
|
|
// automatically trigger a change event, so we trigger one manually here.
|
|
// On browsers that do trigger change, this operation appears to be
|
|
// idempotent, as el.files doesn't change between events.
|
|
$el.trigger("change");
|
|
}
|
|
},
|
|
_isIE9: function() {
|
|
try {
|
|
return (window.navigator.userAgent.match(/MSIE 9\./) && true) || false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
subscribe: function(el, callback) {
|
|
let $el = $(el).on("change.fileInputBinding", uploadFiles);
|
|
// Here we try to set up the necessary events for Drag and Drop ("DnD") on
|
|
// every browser except IE9. We specifically exclude IE9 because it's one
|
|
// browser that supports just enough of the functionality we need to be
|
|
// confusing. In particular, it supports drag events, so drop zones will
|
|
// highlight when a file is dragged into the browser window. It doesn't
|
|
// support the FileList object though, so the user's expectation that DnD is
|
|
// supported based on this highlighting would be incorrect.
|
|
if (!this._isIE9()) {
|
|
if ($fileInputs.length === 0) this._enableDocumentEvents();
|
|
$fileInputs = $fileInputs.add(el);
|
|
let $zone = this._zoneOf(el),
|
|
{OVER} = this._ZoneClass;
|
|
this._enableDraghover($zone)
|
|
.on({
|
|
"draghover:enter.draghover": e => {
|
|
$zone.addClass(OVER);
|
|
},
|
|
"draghover:leave.draghover": e => {
|
|
$zone.removeClass(OVER);
|
|
// Prevent this event from bubbling to the document handler,
|
|
// which would deactivate all zones.
|
|
e.stopPropagation();
|
|
},
|
|
"draghover:drop.draghover": (e, dropEvent) => {
|
|
this._handleDrop(dropEvent, el);
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
unsubscribe: function(el) {
|
|
let $el = $(el),
|
|
$zone = this._zoneOf(el);
|
|
|
|
$zone
|
|
.removeClass(this._ZoneClass.OVER)
|
|
.removeClass(this._ZoneClass.ACTIVE);
|
|
|
|
this._disableDraghover($zone);
|
|
$el.off(".fileInputBinding");
|
|
$zone.off(".draghover");
|
|
|
|
// Remove el from list of inputs and (maybe) clean up global event handlers.
|
|
$fileInputs = $fileInputs.not(el);
|
|
if ($fileInputs.length === 0) this._disableDocumentEvents();
|
|
}
|
|
});
|
|
inputBindings.register(fileInputBinding, 'shiny.fileInputBinding');
|