Simplify DnD for fileInputs, fix #2142 (Firefox 57+)

- Simplified dragHover "plugin" by counting children instead of storing them.
  Counting children fixes Firefox 57+ bug (to be found or filed) that causes
  text object of input element to produce drag events
- Removed multimethod since it's no longer used anywhere
- Firefox 57+ appears not to trigger a change event when the `files` field is modified,
  which prevented uploads from occuring. This commit triggers a change event manually
  and doesn't impact the functioning of other browsers.
This commit is contained in:
Alan Dipert
2018-10-08 21:24:18 -07:00
parent b25cb0f2d5
commit fcd09e2bae
7 changed files with 178 additions and 673 deletions

View File

@@ -309,72 +309,77 @@ $.extend(fileInputBinding, {
// This will be used only when restoring a file from a saved state.
return 'shiny.file';
},
_getZone: function(el) {
_zoneOf: 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.length === 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.length === 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");
// 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);
}
},
"draghoverend.fileDrag": e => {
$fileInputs.trigger("hideZone.fileDrag");
"dragleave.draghover": e => {
if (--childCounter === 0) {
$el.trigger("draghover:leave", e);
}
if (childCounter < 0) {
console.error("draghover childCounter is negative somehow");
}
},
"dragover.fileDrag drop.fileDrag": e => {
"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(".fileDrag");
$doc.off(".draghover");
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";
@@ -402,10 +407,13 @@ $.extend(fileInputBinding, {
// (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");
}
},
_activeClass: "shiny-file-input-active",
_overClass: "shiny-file-input-over",
_isIE9: function() {
try {
return (window.navigator.userAgent.match(/MSIE 9\./) && true) || false;
@@ -414,7 +422,7 @@ $.extend(fileInputBinding, {
}
},
subscribe: function(el, callback) {
let $el = $(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
@@ -423,88 +431,39 @@ $.extend(fileInputBinding, {
// 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");
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);
}
});
}
$el.on("change.fileInputBinding", uploadFiles);
},
unsubscribe: function(el) {
let $el = $(el),
$zone = this._getZone(el);
$zone = this._zoneOf(el);
$el.removeData("state");
$zone.removeClass(this._overClass);
$zone.removeClass(this._activeClass);
$zone
.removeClass(this._ZoneClass.OVER)
.removeClass(this._ZoneClass.ACTIVE);
this._disableDraghover($zone);
// Clean up local event handlers.
$el.off(".fileInputBinding");
$zone.off(this._zoneEvents);
$zone.off(".draghover");
// Remove el from list of inputs and (maybe) clean up global event handlers.
$fileInputs = $fileInputs.not(el);

View File

@@ -326,199 +326,3 @@ exports.compareVersion = function(a, op, b) {
else if (op === "<") return (diff < 0);
else throw `Unknown operator: ${op}`;
};
// multimethod: Creates functions — "multimethods" — that are polymorphic on one
// or more of their arguments.
//
// Multimethods can take any number of arguments. Arguments are passed to an
// applicable function or "method", returning its result. By default, if no
// method was applicable, an exception is thrown.
//
// Methods are searched in the order that they were added, and the first
// applicable method found is the one used.
//
// A method is applicable when the "dispatch value" associated with it
// corresponds to the value returned by the dispatch function. The dispatch
// function defaults to the value of the first argument passed to the
// multimethod.
//
// The correspondence between the value returned by the dispatch function and
// any method's dispatch value is determined by the test function, which is
// user-definable and defaults to `equal` or deep equality.
//
// # Chainable Functions
//
// The function returned by `multimethod()` exposes functions as properties.
// These functions generally return the multimethod, and so can be chained.
//
// - dispatch([function newDispatch]): Sets the dispatch function. The dispatch
// function can take any number of arguments, but must return a dispatch
// value. The default dispatch function returns the first argument passed to
// the multimethod.
//
// - test([function newTest]): Sets the test function. The test function takes
// two arguments: the dispatch value produced by the dispatch function, and
// the dispatch value associated with some method. It must return a boolean
// indicating whether or not to select the method. The default test function
// is `equal`.
//
// - when(object dispatchVal, function method): Adds a new dispatch value/method
// combination.
//
// - whenAny(array<object> dispatchVals, function method): Like `when`, but
// associates the method with every dispatch value in the `dispatchVals`
// array.
//
// - else(function newDefaultMethod): Sets the default function. This function
// is invoked when no methods apply. If left unset, the multimethod will throw
// an exception when no methods are applicable.
//
// - clone(): Returns a new, functionally-equivalent multimethod. This is a way
// to extend an existing multimethod in a local context — such as inside a
// function — without modifying the original. NOTE: The array of methods is
// copied, but the dispatch values themselves are not.
//
// # Self-reference
//
// The multimethod function can be obtained inside its method bodies without
// referring to it by name.
//
// This makes it possible for one method to call another, or to pass the
// multimethod to other functions as a callback from within methods.
//
// The mechanism is: the multimethod itself is bound as `this` to methods when
// they are called. Since arrow functions cannot be bound to objects, **self-reference
// is only possible within methods created using the `function` keyword**.
//
// # Tail recursion
//
// A method can call itself in a way that will not overflow the stack by using
// `this.recur`.
//
// `this.recur` is a function available in methods created using `function`.
// When the return value of a call to `this.recur` is returned by a method, the
// arguments that were supplied to `this.recur` are used to call the
// multimethod.
//
// # Examples
//
// Handling events:
//
// var handle = multimethod()
// .dispatch(e => [e.target.tagName.toLowerCase(), e.type])
// .when(["h1", "click"], e => "you clicked on an h1")
// .when(["p", "mouseover"], e => "you moused over a p"})
// .else(e => {
// let tag = e.target.tagName.toLowerCase();
// return `you did ${e.type} to an ${tag}`;
// });
//
// $(document).on("click mouseover mouseup mousedown", e => console.log(handle(e)))
//
// Self-calls:
//
// var demoSelfCall = multimethod()
// .when(0, function(n) {
// this(1);
// })
// .when(1, function(n) {
// doSomething(this);
// })
// .when(2, _ => console.log("tada"));
//
// Using (abusing?) the test function:
//
// var fizzBuzz = multimethod()
// .test((x, divs) => divs.map(d => x % d === 0).every(Boolean))
// .when([3, 5], x => "FizzBuzz")
// .when([3], x => "Fizz")
// .when([5], x => "Buzz")
// .else(x => x);
//
// for(let i = 0; i <= 100; i++) console.log(fizzBuzz(i));
//
// Getting carried away with tail recursion:
//
// var factorial = multimethod()
// .when(0, () => 1)
// .when(1, (_, prod = 1) => prod)
// .else(function(n, prod = 1) {
// return this.recur(n-1, n*prod);
// });
//
// var fibonacci = multimethod()
// .when(0, (_, a = 0) => a)
// .else(function(n, a = 0, b = 1) {
// return this.recur(n-1, b, a+b);
// });
function multimethod(dispatch = (firstArg) => firstArg,
test = equal,
defaultMethod = null,
methods = []) {
var trampolining = false;
function Sentinel (args) { this.args = args; }
function trampoline(f) {
return (...args) => {
trampolining = true;
var ret = f.apply(invoke, args);
while (ret instanceof Sentinel)
ret = f.apply(invoke, ret.args);
trampolining = false;
return ret;
};
}
let invoke = trampoline((...args) => {
var dispatchVal = dispatch.apply(null, args);
for (let i = 0; i < methods.length; i++) {
let [methodVal, methodFn] = methods[i];
if (test(dispatchVal, methodVal)) {
return methodFn.apply(invoke, args);
}
}
if (defaultMethod) {
return defaultMethod.apply(invoke, args);
} else {
throw new Error(`No method for dispatch value ${dispatchVal}`);
}
});
invoke.recur = (...args) => {
if (!trampolining) throw new Error("recur can only be called inside a method");
return new Sentinel(args);
};
invoke.dispatch = (newDispatch) => {
dispatch = newDispatch;
return invoke;
};
invoke.test = (newTest) => {
test = newTest;
return invoke;
};
invoke.when = (dispatchVal, methodFn) => {
methods = methods.concat([[dispatchVal, methodFn]]);
return invoke;
};
invoke.whenAny = (dispatchVals, methodFn) => {
return dispatchVals.reduce((self, val) => invoke.when(val, methodFn), invoke);
};
invoke.else = (newDefaultMethod = null) => {
defaultMethod = newDefaultMethod;
return invoke;
};
invoke.clone = () => {
return multimethod(dispatch, test, defaultMethod, methods.slice());
};
return invoke;
}