From 31cc704ee2fbde1a2d18aa9f9e66644fd3420a3a Mon Sep 17 00:00:00 2001 From: Alan Dipert Date: Fri, 5 Oct 2018 22:40:39 -0700 Subject: [PATCH] Simplify DnD for fileInputs, fix Firefox - 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 --- srcjs/input_binding_fileinput.js | 223 ++++++++++--------------------- srcjs/utils.js | 196 --------------------------- 2 files changed, 72 insertions(+), 347 deletions(-) diff --git a/srcjs/input_binding_fileinput.js b/srcjs/input_binding_fileinput.js index 72859fed2..54fbabe7a 100644 --- a/srcjs/input_binding_fileinput.js +++ b/srcjs/input_binding_fileinput.js @@ -309,78 +309,67 @@ $.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, startEvent = "draghoverstart", endEvent = "draghoverend") { - // 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(startEvent, 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); - }); - - // If a drop happens, clear the collection and trigger a draghoverend. - $el.on("drop.dragHover", e => { - collection = $(); - $el.trigger(endEvent, e.originalEvent); - }); - - // Attach dragleave to $el and its children. Whenever a - // child fires either of these events, remove it from the collection. - $el.on("dragleave.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(endEvent, e.originalEvent); - } - }); - }, - _disableDraghover: function($el) { - $el.off(".dragHover"); - }, - _enableDocumentEvents: function() { - let $doc = $("html"); - - this._enableDraghover($doc); - $doc.on({ - "draghoverstart.fileDrag": e => { - $fileInputs.trigger("showZone"); + _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"); + "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", - "hideZone", - "draghoverstart:zone", - "draghoverend:zone", - "drop" - ].join(" "), _canSetFiles: function(fileList) { var testEl = document.createElement("input"); testEl.type = "file"; @@ -415,8 +404,6 @@ $.extend(fileInputBinding, { $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; @@ -425,7 +412,7 @@ $.extend(fileInputBinding, { } }, subscribe: function(el, callback) { - let $el = $(el); + 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 @@ -434,105 +421,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"); - }) - .whenAny([ - // If we're in the dropped-on state and we receive a draghoverend:zone - // event, it means that we've just handled a file drop and need to be in - // the plain state. - ["dropped-on", "draghoverend:zone"], - // If we're plain and receive hideZone, it means we've made ourselves - // plain already and are now receiving the hideZone message sent to all - // file inputs after a drag has stopped. - ["plain", "hideZone"], - // If we're activated and receive a hideZone, it means the drag has - // left the browser window and we need to be plain. This can happen - // when the browser is occluded by an OS window and the user drags - // the file from the browser back to that window. - ["activated", "hideZone"]], e => { - $zone.removeClass(this._overClass); - $zone.removeClass(this._activeClass); - setState("plain"); - }) - .when(["activated", "draghoverstart:zone"], e => { - $zone.addClass(this._overClass); - $zone.removeClass(this._activeClass); - setState("over"); - }) - // Here we handle the drop event, and enter the "dropped-on" state. - // This is necessary because draghoverend:zone can denote that either - // the file was dragged away *or* that it has been dropped on us, and - // we will need to know what happened in order to properly interpret - // the draghoverend:zone event that was also triggered by this drop. - .when(["over", "drop"], e => { - this._handleDrop(e, el); - setState("dropped-on"); - }) - // If we're in the "over" state and we receive draghoverend:zone, it means - // the file has been dragged away and *not* dropped on us, since our state - // is not "dropped-on" - .when(["over", "draghoverend:zone"], 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:zone"], 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, "draghoverstart:zone", "draghoverend: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); diff --git a/srcjs/utils.js b/srcjs/utils.js index 6bffb52c0..0007702c3 100644 --- a/srcjs/utils.js +++ b/srcjs/utils.js @@ -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 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; -}