Files
shiny/srcjs/input_binding_fileinput.js
Alan Dipert 908d635063 Fix #2349, #2329, #1817: bugs triggered by networkD3 sankey plot
* All of these were caused by the presence of multiple body tags on the
page, which happened because networkD3's sankey plot generates SVGs
containing body tags via SVG's foreignObject tag
* In various places, the 'body' jQuery selector string is used under the
assumption there is only one 'body' tag on the page. The presence of
multiple 'body' tags breaks reliant code in strange ways.
* The fix was to use document.body or 'body:first' instead of 'body'.
2019-03-27 11:36:19 -07:00

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');
$(document.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) {
$(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');