mirror of
https://github.com/rstudio/shiny.git
synced 2026-02-07 13:15:00 -05:00
* fileInput WIP: Show dropzones when file dragged over window - Still need to validate dataTransfer contents * WIP: Basic functionality working * wip * Grunt * WIP state machine * WIP generalize FSM to data+multimethod * WIP multimethod * WIP draghover * wip multimethod * WIP, such refactor * WIP: rm multimethod * WIP * WIP resurrect multimethod * WIP move draghover functions into input object * WIP colors: use more muted, bootstrap-esque glows * Grunt * WIP: use whenAny, more descriptive args in default test/dispatch fns * WIP more whenAny * Grunt * WIP dont use for...of, requires polyfill * Grunt * multimethod improvements, documentation. `equal` function. * multimethod: simplified equal, removed need for forward decl. docs. * dox * multimethod improvements, docs * minor * IE 10+ drag/drop, first cut * Grunt * use functions not arrows for faux instance methods * Grunt * fix uploadDropped call * Grunt * cleanup drop handler, fix entry to invalid state via doc drop handler * Grunt * IE workaround #293932 * Grunt * yeeeeeeeeeeessss IE WORKSSSSS * Cleanup; support activeClass/overClass * everything basically works everywhere \o/ * revert ability to specify classes, hardcode in JS * MM fixes * minor fixes * Grunt * DnD: Support dragging directly over zones - Happens when source window occludes browser window * woo * Note Safari bug, use draghover for zones * merge * Grunt * news * include CSS
515 lines
17 KiB
JavaScript
515 lines
17 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';
|
|
},
|
|
_getZone: function(el) {
|
|
return $(el).closest("div.input-group");
|
|
},
|
|
// This implements draghoverstart/draghoverend events that occur once per
|
|
// selector, instead of once for every child the way native
|
|
// dragenter/dragleave do. Inspired by https://gist.github.com/meleyal/3794126
|
|
_enableDraghover: function($el, ns = "") {
|
|
// Create an empty jQuery collection. This is a set-like data structure that
|
|
// jQuery normally uses to contain the results of a selection.
|
|
let collection = $();
|
|
|
|
// Attach a dragenter handler to $el and all of its children. When the first
|
|
// child is entered, trigger a draghoverstart event.
|
|
$el.on("dragenter.dragHover", e => {
|
|
if (collection.size() === 0) {
|
|
$el.trigger("draghoverstart" + ns, e.originalEvent);
|
|
}
|
|
// Every child that has fired dragenter is added to the collection.
|
|
// Addition is idempotent, which accounts for elements producing dragenter
|
|
// multiple times.
|
|
collection = collection.add(e.originalEvent.target);
|
|
});
|
|
|
|
// Attach dragleave and drop handlers to $el and its children. Whenever a
|
|
// child fires either of these events, remove it from the collection.
|
|
$el.on("dragleave.dragHover drop.dragHover", e => {
|
|
collection = collection.not(e.originalEvent.target);
|
|
// When the collection has no elements, all of the children have been
|
|
// removed, and produce draghoverend event.
|
|
if (collection.size() === 0) {
|
|
$el.trigger("draghoverend" + ns, e.originalEvent);
|
|
}
|
|
});
|
|
},
|
|
_disableDraghover: function($el) {
|
|
$el.off(".dragHover");
|
|
},
|
|
_enableDocumentEvents: function() {
|
|
let $doc = $("html");
|
|
|
|
this._enableDraghover($doc);
|
|
$doc.on({
|
|
"draghoverstart.fileDrag": e => {
|
|
$fileInputs.trigger("showZone.fileDrag");
|
|
},
|
|
"draghoverend.fileDrag": e => {
|
|
$fileInputs.trigger("hideZone.fileDrag");
|
|
},
|
|
"dragover.fileDrag drop.fileDrag": e => {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
},
|
|
_disableDocumentEvents: function() {
|
|
let $doc = $("html");
|
|
|
|
$doc.off(".fileDrag");
|
|
this._disableDraghover($doc);
|
|
},
|
|
_zoneEvents: [
|
|
"showZone.fileDrag",
|
|
"hideZone.fileDrag",
|
|
"draghoverstart.zone",
|
|
"draghoverend.zone",
|
|
"drop"
|
|
].join(" "),
|
|
_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;
|
|
}
|
|
},
|
|
_activeClass: "shiny-file-input-active",
|
|
_overClass: "shiny-file-input-over",
|
|
_isIE9: function() {
|
|
try {
|
|
return (window.navigator.userAgent.match(/MSIE 9\./) && true) || false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
subscribe: function(el, callback) {
|
|
let $el = $(el);
|
|
// 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()) {
|
|
let $zone = this._getZone(el),
|
|
getState = () => $el.data("state"),
|
|
setState = (newState) => $el.data("state", newState),
|
|
transition = multimethod()
|
|
.dispatch(e => [getState(), e.type])
|
|
.when(["plain", "showZone"], e => {
|
|
$zone.removeClass(this._overClass);
|
|
$zone.addClass(this._activeClass);
|
|
setState("activated");
|
|
})
|
|
.when(["activated", "hideZone"], e => {
|
|
$zone.removeClass(this._overClass);
|
|
$zone.removeClass(this._activeClass);
|
|
setState("plain");
|
|
})
|
|
.when(["activated", "draghoverstart"], e => {
|
|
$zone.addClass(this._overClass);
|
|
$zone.removeClass(this._activeClass);
|
|
setState("over");
|
|
})
|
|
// A "drop" event always coincides with a "draghoverend" event. Since
|
|
// we handle all draghoverend events the same way, by clearing our
|
|
// over-style and reverting to "activated" state, we only need to
|
|
// worry about handling the file upload itself here.
|
|
.when(["over", "drop"], e => {
|
|
this._handleDrop(e, el);
|
|
// State change taken care of by ["over", "draghoverend"] handler.
|
|
})
|
|
.when(["over", "draghoverend"], e => {
|
|
$zone.removeClass(this._overClass);
|
|
$zone.addClass(this._activeClass);
|
|
setState("activated");
|
|
})
|
|
// This next case happens when the window (like Finder) that a file is
|
|
// being dragged from occludes the browser window, and the dragged
|
|
// item first enters the page over a drop zone instead of entering
|
|
// through a none-zone element.
|
|
//
|
|
// The dragenter event that caused this draghoverstart to occur will
|
|
// bubble to the document, where it will cause a showZone event to be
|
|
// fired, and drop zones will activate and their states will
|
|
// transition to "activated".
|
|
//
|
|
// We schedule a function to be run *after* that happens, using
|
|
// setTimeout. The function we schedule will set the current element's
|
|
// state to "over", preparing us to deal with a subsequent
|
|
// "draghoverend".
|
|
.when(["plain", "draghoverstart"], e => {
|
|
window.setTimeout(() => {
|
|
$zone.addClass(this._overClass);
|
|
$zone.removeClass(this._activeClass);
|
|
setState("over");
|
|
}, 0);
|
|
})
|
|
.else(e => {
|
|
console.log("fileInput DnD unhandled transition", getState(), e.type, e);
|
|
});
|
|
|
|
if ($fileInputs.length === 0) this._enableDocumentEvents();
|
|
setState("plain");
|
|
$zone.on(this._zoneEvents, transition);
|
|
$fileInputs = $fileInputs.add(el);
|
|
this._enableDraghover($zone, ".zone");
|
|
}
|
|
|
|
$el.on("change.fileInputBinding", uploadFiles);
|
|
},
|
|
|
|
unsubscribe: function(el) {
|
|
let $el = $(el),
|
|
$zone = this._getZone(el);
|
|
|
|
$el.removeData("state");
|
|
|
|
$zone.removeClass(this._overClass);
|
|
$zone.removeClass(this._activeClass);
|
|
|
|
this._disableDraghover($zone);
|
|
|
|
// Clean up local event handlers.
|
|
$el.off(".fileInputBinding");
|
|
$zone.off(this._zoneEvents);
|
|
|
|
// 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');
|