Merge pull request #622 from mathquill/feature.fastClick-rebase
API to enable "fast clicks" on touch devices
Argghhhhh. Want touch swipes to scroll the page, even if they start in
MathQuill (so that it's possible to scroll an expression list filled to
the brim with nothing but MathQuills), but touch taps to place the
cursor. Now, with mouse events, if all you care about is a logical
"click" on the current platform, you just listen for the click event and
the browser will take care of whether a particular sequence of
mousedown-mousemoves-mouseup is a click or not. With touch events,
there's no equivalent logical "tap" event, and while legacy mouse events
do fire if you don't .preventDefault() on touchstart, Safari on iOS waits
a glacial ~360ms [1] after a tap in case you're actually doing the
double-tap gesture to zoom in.
[1]: Videos: http://developer.telerik.com/featured/300-ms-click-delay-ios-8/
It gets better. Not only are the legacy mouse events unhelpful, they're
misleading, because there is no way to directly tell them apart from
real mouse events and no way to get them not to fire except calling
.preventDefault(), which breaks touch-swipe-to-scroll.
As a result, across the Web there are dozens of blog posts about, and
shitty JS libraries for, "fast clicks", that all disagree on what counts
as a "tap", and also when to ignore a mouse event because it's probably
a legacy one 300ms after a touch event that's already been dealt with.
By far the 2 most-cited "fast click" solutions are Google Developer's
"Fast Buttons" article [2] and FT Labs' FastClick library [3]; only the
latter ignores taps that are to stop scrolling after a fling, taps where
touchend is >700ms after touchstart, or taps with more than one finger,
but only the former appears to put a time and distance limit on mouse
events to ignore (FastClick just ignores the next one).
[2]: https://web.archive.org/web/20150406091222/https://developers.google.com/mobile/articles/fast_buttons
[3]: https://github.com/ftlabs/fastclick
(Google seems to have lost the original widely-cited "Fast Buttons" article: https://developers.google.com/mobile/articles/fast_buttons )
MathQuill's sponsor, Desmos, has a custom touch events library whose
definition of "tap", and whose strategy for ignoring delayed mouse
events, is different from either of the above. Clearly, this stuff needs
to be decided by the parent application using MathQuill.
So, with heart-wrenching despair, here are 2 new API calls:
- .clickAt(clientX, clientY, target) takes in the touch event info
and places the cursor like there was a click there (also useful for
e.g. obviating .dropEmbedded())
+ `target` is optional but optimizes-away one DOM call for free
- .ignoreNextMousedown(fn) takes a function that is called on subsequent
mousedown events, which MathQuill will ignore if true is returned.
MathQuill stops calling the function once it returns false.
You should call this whenever there might be a legacy mousedown, even
if it doesn't "count" as a tap to you, else MathQuill will get clicked
fast sometimes and slow others, that'd be weird.
--
Why clientX/Y even though .seek() takes in pageX/Y? Well,
document.elementFromPoint() *has* to take in clientX/Y, whereas .seek()
merely *happens* to take in pageX/Y because it's slightly easier to work
with jQuery::offset(), and that's likely to change because jQuery::offset()
has poor performance so MathQuill may want to start working with
.getBoundingClientRects() directly, which would make it actually easier to
work with clientX/Y than pageX/Y in the first place.
For now, though, I'm not gonna port everything over the whole .seek() system
to clientX/Y yet, instead .clickAt() just converts clientX/Y to pageX/Y by
way of window.pageXOffset and window.pageYOffset, which according to
QuirksMode [4] is the difference between them and has very good support.
(It's also used by jQuery to convert from clientX/Y to what jQuery::offset()
returns [5].)
[4]: http://www.quirksmode.org/mobile/tableViewport.html#t10
[5]: https://github.com/jquery/jquery/blob/1.12.3/src/offset.js#L113-L114
--
The test case has a trivial notion of logical "tap", which is if no
touchmoves happen between the touchstart and touchend. Note that it
calls .ignoreNextMousedown() after every touchend, or else sometimes if
you wiggle your finger a little but not much, the logical "tap" won't
happen but Safari on iOS will still fire a legacy mousedown event.
Test case also gained -webkit-tap-highlight-color to to hide the gray
tap highlight box.diff --git a/src/publicapi.js b/src/publicapi.js
index 022dfda..2ccdeb2 100644
--- a/src/publicapi.js
+++ b/src/publicapi.js
@@ -213,6 +213,19 @@
var cmd = Embed().setOptions(options);
cmd.createLeftOf(this.__controller.cursor);
};
+ _.clickAt = function(clientX, clientY, target) {
+ target = target || document.elementFromPoint(clientX, clientY);
+
+ var ctrlr = this.__controller, root = ctrlr.root;
+ if (!jQuery.contains(root.jQ[0], target)) target = root.jQ[0];
+ ctrlr.seek($(target), clientX + pageXOffset, clientY + pageYOffset);
+ if (ctrlr.blurred) this.focus();
+ return this;
+ };
+ _.ignoreNextMousedown = function(fn) {
+ this.__controller.cursor.options.ignoreNextMousedown = fn;
+ return this;
+ };
});
MQ.EditableField = function() { throw "wtf don't call me, I'm 'abstract'"; };
MQ.EditableField.prototype = APIClasses.EditableField.prototype;
diff --git a/src/services/mouse.js b/src/services/mouse.js
index c060461..b2b543a 100644
--- a/src/services/mouse.js
+++ b/src/services/mouse.js
@@ -3,6 +3,7 @@
*******************************************************/
Controller.open(function(_) {
+ Options.p.ignoreNextMousedown = noop;
_.delegateMouseEvents = function() {
var ultimateRootjQ = this.root.jQ;
//drag-to-select event handling
@@ -12,6 +13,12 @@
var ctrlr = root.controller, cursor = ctrlr.cursor, blink = cursor.blink;
var textareaSpan = ctrlr.textareaSpan, textarea = ctrlr.textarea;
+ e.preventDefault(); // doesn't work in IE≤8, but it's a one-line fix:
+ e.target.unselectable = true; // http://jsbin.com/yagekiji/1
+
+ if (cursor.options.ignoreNextMousedown(e)) return;
+ else cursor.options.ignoreNextMousedown = noop;
+
var target;
function mousemove(e) { target = $(e.target); }
function docmousemove(e) {
@@ -42,8 +49,6 @@
if (!ctrlr.editable) rootjQ.prepend(textareaSpan);
textarea.focus();
}
- e.preventDefault(); // doesn't work in IE≤8, but it's a one-line fix:
- e.target.unselectable = true; // http://jsbin.com/yagekiji/1
cursor.blink = noop;
ctrlr.seek($(e.target), e.pageX, e.pageY).cursor.startSelection();
diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js
index a2c62c3..9264c3e 100644
--- a/test/unit/publicapi.test.js
+++ b/test/unit/publicapi.test.js
@@ -762,6 +762,58 @@
});
});
+ suite('clickAt', function() {
+ test('inserts at coordinates', function() {
+ // Insert filler so that the page is taller than the window so this test is deterministic
+ // Test that we use clientY instead of pageY
+ var windowHeight = $(window).height();
+ var filler = $('<div>').height(windowHeight);
+ filler.insertBefore('#mock');
+
+ var mq = MQ.MathField($('<span>').appendTo('#mock')[0]);
+ mq.typedText("mmmm/mmmm");
+ mq.el().scrollIntoView();
+
+ var box = mq.el().getBoundingClientRect();
+ var clientX = box.left + 30;
+ var clientY = box.top + 40;
+ var target = document.elementFromPoint(clientX, clientY);
+
+ assert.equal(document.activeElement, document.body);
+ mq.clickAt(clientX, clientY, target).write('x');
+ assert.equal(document.activeElement, $(mq.el()).find('textarea')[0]);
+
+ assert.equal(mq.latex(), "\\frac{mmmm}{mmxmm}");
+
+ filler.remove();
+ $(mq.el()).remove();
+ });
+ test('target is optional', function() {
+ // Insert filler so that the page is taller than the window so this test is deterministic
+ // Test that we use clientY instead of pageY
+ var windowHeight = $(window).height();
+ var filler = $('<div>').height(windowHeight);
+ filler.insertBefore('#mock');
+
+ var mq = MQ.MathField($('<span>').appendTo('#mock')[0]);
+ mq.typedText("mmmm/mmmm");
+ mq.el().scrollIntoView();
+
+ var box = mq.el().getBoundingClientRect();
+ var clientX = box.left + 30;
+ var clientY = box.top + 40;
+
+ assert.equal(document.activeElement, document.body);
+ mq.clickAt(clientX, clientY).write('x');
+ assert.equal(document.activeElement, $(mq.el()).find('textarea')[0]);
+
+ assert.equal(mq.latex(), "\\frac{mmmm}{mmxmm}");
+
+ filler.remove();
+ $(mq.el()).remove();
+ });
+ });
+
suite('dropEmbedded', function() {
test('inserts into empty', function() {
var mq = MQ.MathField($('<span>').appendTo('#mock')[0]);
diff --git a/test/visual.html b/test/visual.html
index 1c6f44f..027785e 100644
--- a/test/visual.html
+++ b/test/visual.html
@@ -77,7 +77,7 @@
<td><span class="mathquill-static-math">\sqrt{\MathQuillMathField{x^2+y^2}}</span>
</table>
-<p>Clicks/mousedown to drag should work anywhere in the blue box: <div class="math-container" style="border: solid 1px lightblue; height: 5em; width: 15em; line-height: 5em; text-align: center"><span class="mathquill-math-field">x_{very\ long\ thing}^2 + a_0 = 0</span></div>
+<p>Touch taps/clicks/mousedown to drag should work anywhere in the blue box: <div class="math-container" style="border: solid 1px lightblue; height: 5em; width: 15em; line-height: 5em; text-align: center; -webkit-tap-highlight-color: rgba(0,0,0,0)"><span class="mathquill-math-field">a_2 x^2 + a_1 x + a_0 = 0</span></div>
<h3>Redrawing</h3>
<p>
@@ -297,7 +297,25 @@
// test selecting from outside the mathquill editable
var $mq = $('.math-container .mathquill-math-field');
$('.math-container').mousedown(function(e) {
- if (!jQuery.contains($mq[0], e.target)) $mq.triggerHandler(e);
+ if (e.target === $mq[0] || $.contains($mq[0], e.target)) return;
+ $mq.triggerHandler(e);
+})
+// test API for "fast touch taps" #622 & #403
+.on('touchstart', function() {
+ var moved = false;
+ $(this).on('touchmove.tmp', function() { moved = true; })
+ .on('touchend.tmp', function(e) {
+ $(this).off('.tmp');
+ MathQuill($mq[0]).ignoreNextMousedown(function() {
+ return Date.now() < e.timeStamp + 1000;
+ });
+ if (moved) return; // note that this happens after .ignoreNextMousedown()
+ // because even if the touch gesture doesn't 'count' as a tap to us,
+ // we still want to suppress the legacy mouse events, else we'd react
+ // fast to some taps and slow to others, that'd be weird
+ var touch = e.originalEvent.changedTouches[0];
+ MathQuill($mq[0]).clickAt(touch.clientX, touch.clientY, touch.target);
+ });
});
// Selection Tests