Merge pull request #627 from mathquill/fix.visual-rendering

Improve visual rendering of integrals
diff --git a/README.md b/README.md
index 956bee5..ff01a2d 100644
--- a/README.md
+++ b/README.md
@@ -280,10 +280,11 @@
 `autoOperatorNames`, a list of the same form (space-delimited letters-only each
 length>=2), and overrides the set of operator names that automatically become
 non-italicized when typing the letters without typing a backslash first, like
-`sin`, `log`, etc. (Defaults to [the LaTeX built-in operator names][Wikia], but
-with additional trig operators like `sech`, `arcsec`, `arsinh`, etc.)
+`sin`, `log`, etc. Defaults to the LaTeX built-in operator names ([Section
+3.17 of the Short Math Guide][3.17]) plus some missing trig operators like
+`sech`, `arcsec`, `arsinh`, etc.
 
-[Wikia]: http://latex.wikia.com/wiki/List_of_LaTeX_symbols#Named_operators:_sin.2C_cos.2C_etc.
+[3.17]: http://tinyurl.com/jm9okjc
 
 `substituteTextarea`, a function that creates a focusable DOM element, called
 when setting up a math field. It defaults to `<textarea autocorrect=off .../>`,
diff --git a/package.json b/package.json
index e9889de..cedaec8 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "url": "https://github.com/mathquill/mathquill.git"
   },
   "dependencies": {
-    "pjs": "3.x"
+    "pjs": ">=3.1.0"
   },
   "devDependencies": {
     "mocha": "*",
diff --git a/src/commands/math/basicSymbols.js b/src/commands/math/basicSymbols.js
index 06ea85c..a8578ae 100644
--- a/src/commands/math/basicSymbols.js
+++ b/src/commands/math/basicSymbols.js
@@ -59,26 +59,27 @@
 var Letter = P(Variable, function(_, super_) {
   _.init = function(ch) { return super_.init.call(this, this.letter = ch); };
   _.createLeftOf = function(cursor) {
+    super_.createLeftOf.apply(this, arguments);
     var autoCmds = cursor.options.autoCommands, maxLength = autoCmds._maxLength;
     if (maxLength > 0) {
       // want longest possible autocommand, so join together longest
       // sequence of letters
-      var str = this.letter, l = cursor[L], i = 1;
-      while (l instanceof Letter && i < maxLength) {
+      var str = '', l = this, i = 0;
+      // FIXME: l.ctrlSeq === l.letter checks if first or last in an operator name
+      while (l instanceof Letter && l.ctrlSeq === l.letter && i < maxLength) {
         str = l.letter + str, l = l[L], i += 1;
       }
       // check for an autocommand, going thru substrings longest to shortest
       while (str.length) {
         if (autoCmds.hasOwnProperty(str)) {
-          for (var i = 2, l = cursor[L]; i < str.length; i += 1, l = l[L]);
-          Fragment(l, cursor[L]).remove();
+          for (var i = 1, l = this; i < str.length; i += 1, l = l[L]);
+          Fragment(l, this).remove();
           cursor[L] = l[L];
           return LatexCmds[str](str).createLeftOf(cursor);
         }
         str = str.slice(1);
       }
     }
-    super_.createLeftOf.apply(this, arguments);
   };
   _.italicize = function(bool) {
     this.isItalic = bool;
@@ -103,7 +104,7 @@
     // removeClass and delete flags from all letters before figuring out
     // which, if any, are part of an operator name
     Fragment(l[R] || this.parent.ends[L], r[L] || this.parent.ends[R]).each(function(el) {
-      el.italicize(true).jQ.removeClass('mq-first mq-last');
+      el.italicize(true).jQ.removeClass('mq-first mq-last mq-followed-by-supsub');
       el.ctrlSeq = el.letter;
     });
 
@@ -123,9 +124,19 @@
           last.ctrlSeq += (isBuiltIn ? ' ' : '}');
           if (TwoWordOpNames.hasOwnProperty(word)) last[L][L][L].jQ.addClass('mq-last');
           if (!shouldOmitPadding(first[L])) first.jQ.addClass('mq-first');
-          if (!shouldOmitPadding(last[R]) && !(last[R] instanceof Bracket)) {
-            // also omit space between operator name and paren like in sin(x)
-            last.jQ.addClass('mq-last');
+          if (!shouldOmitPadding(last[R])) {
+            if (last[R] instanceof SupSub) {
+              var supsub = last[R]; // XXX monkey-patching, but what's the right thing here?
+              // Have operatorname-specific code in SupSub? A CSS-like language to style the
+              // math tree, but which ignores cursor and selection (which CSS can't)?
+              var respace = supsub.siblingCreated = supsub.siblingDeleted = function() {
+                supsub.jQ.toggleClass('mq-after-operator-name', !(supsub[R] instanceof Bracket));
+              };
+              respace();
+            }
+            else {
+              last.jQ.toggleClass('mq-last', !(last[R] instanceof Bracket));
+            }
           }
 
           i += len - 1;
@@ -141,7 +152,7 @@
   }
 });
 var BuiltInOpNames = {}; // the set of operator names like \sin, \cos, etc that
-  // are built-into LaTeX: http://latex.wikia.com/wiki/List_of_LaTeX_symbols#Named_operators:_sin.2C_cos.2C_etc.
+  // are built-into LaTeX, see Section 3.17 of the Short Math Guide: http://tinyurl.com/jm9okjc
   // MathQuill auto-unitalicizes some operator names not in that set, like 'hcf'
   // and 'arsinh', which must be exported as \operatorname{hcf} and
   // \operatorname{arsinh}. Note: over/under line/arrow \lim variants like
@@ -411,8 +422,11 @@
 
   _.contactWeld = _.siblingCreated = _.siblingDeleted = function(opts, dir) {
     if (dir === R) return; // ignore if sibling only changed on the right
+    // If the left sibling is a binary operator or a separator (comma, semicolon, colon)
+    // or an open bracket (open parenthesis, open square bracket)
+    // consider the operator to be unary, otherwise binary
     this.jQ[0].className =
-      (!this[L] || this[L] instanceof BinaryOperator ? '' : 'mq-binary-operator');
+      (!this[L] || this[L] instanceof BinaryOperator || /^[,;:\(\[]$/.test(this[L].ctrlSeq) ? '' : 'mq-binary-operator');
     return this;
   };
 });
diff --git a/src/css/math.less b/src/css/math.less
index 9b7fb9e..aae409d 100644
--- a/src/css/math.less
+++ b/src/css/math.less
@@ -231,7 +231,7 @@
   var.mq-operator-name.mq-first {
     padding-left: .2em;
   }
-  var.mq-operator-name.mq-last {
+  var.mq-operator-name.mq-last, .mq-supsub.mq-after-operator-name {
     padding-right: .2em;
   }
 
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/css.test.js b/test/unit/css.test.js
index 223895f..639cdf9 100644
--- a/test/unit/css.test.js
+++ b/test/unit/css.test.js
@@ -52,5 +52,58 @@
     var mqF = $(mq.el()).find('.mq-f');
     var testVal = parseFloat(mqF.css('margin-right')) - parseFloat(mqF.css('margin-left'));
     assert.ok(testVal > 0, 'this should be truthy') ;
+
+    $(mq.el()).remove();
+  });
+
+  test('unary PlusMinus before separator', function () {
+    var mq = MQ.MathField($('<span></span>').appendTo('#mock')[0]);
+    mq.latex('(-1,-1-1)-1,(+1;+1+1)+1,(\\pm1,\\pm1\\pm1)\\pm1');
+    var spans = $(mq.el()).find('.mq-root-block').find('span');
+    assert.equal(spans.length, 35, 'PlusMinus expression parsed incorrectly');
+
+    function isBinaryOperator(i) { return $(spans[i]).hasClass('mq-binary-operator'); }
+    function assertBinaryOperator(i, s) { assert.ok(isBinaryOperator(i), '"' + s + '" should be binary'); }
+    function assertUnaryOperator(i, s) { assert.ok(!isBinaryOperator(i), '"' + s + '" should be unary'); }
+
+    assertUnaryOperator(1, '(-');
+    assertUnaryOperator(4, '(-1,-');
+    assertBinaryOperator(6, '(-1,-1-');
+    assertBinaryOperator(9, '(-1,-1-1)-');
+    assertUnaryOperator(13, '(-1,-1-1)-1,(+');
+    assertUnaryOperator(16, '(-1,-1-1)-1,(+1;+');
+    assertBinaryOperator(18, '(-1,-1-1)-1,(+1;+1+');
+    assertBinaryOperator(21, '(-1,-1-1)-1,(+1;+1+1)+');
+    assertUnaryOperator(25, '(-1,-1-1)-1,(+1;+1+1)+1,(\pm');
+    assertUnaryOperator(28, '(-1,-1-1)-1,(+1;+1+1)+1,(\pm1,\pm');
+    assertBinaryOperator(30, '(-1,-1-1)-1,(+1;+1+1)+1,(\pm1,\pm1\pm');
+    assertBinaryOperator(33, '(-1,-1-1)-1,(+1;+1+1)+1,(\pm1,\pm1\pm1)\pm');
+
+    $(mq.el()).remove();
+  });
+
+  test('operator name spacing e.g. sin x', function() {
+    var mq = MathQuill.MathField($('<span></span>').appendTo(mock)[0]);
+
+    mq.typedText('sin');
+    var n = jQuery('#mock var.mq-operator-name:last');
+    assert.equal(n.text(), 'n');
+    assert.ok(!n.is('.mq-last'));
+
+    mq.typedText('x');
+    assert.ok(n.is('.mq-last'));
+
+    mq.keystroke('Left').typedText('(');
+    assert.ok(!n.is('.mq-last'));
+
+    mq.keystroke('Backspace').typedText('^');
+    assert.ok(!n.is('.mq-last'));
+    var supsub = jQuery('#mock .mq-supsub');
+    assert.ok(supsub.is('.mq-after-operator-name'));
+
+    mq.typedText('2').keystroke('Tab').typedText('(');
+    assert.ok(!supsub.is('.mq-after-operator-name'));
+
+    $(mq.el()).empty();
   });
 });
diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js
index 5453e7f..91ef1f0 100644
--- a/test/unit/publicapi.test.js
+++ b/test/unit/publicapi.test.js
@@ -776,6 +776,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/unit/typing.test.js b/test/unit/typing.test.js
index 3ba3b31..25628ef 100644
--- a/test/unit/typing.test.js
+++ b/test/unit/typing.test.js
@@ -813,6 +813,7 @@
   suite('autoCommands', function() {
     setup(function() {
       MQ.config({
+        autoOperatorNames: 'sin pp',
         autoCommands: 'pi tau phi theta Gamma sum prod sqrt nthroot'
       });
     });
@@ -880,6 +881,13 @@
       assertLatex('\\sin\\pi');
     });
 
+    test('has lower "precedence" than operator names', function() {
+      mq.typedText('ppi');
+      assertLatex('\\operatorname{pp}i');
+      mq.keystroke('Left Left').typedText('i');
+      assertLatex('\\pi pi');
+    });
+
     test('command contains non-letters', function() {
       assert.throws(function() { MQ.config({ autoCommands: 'e1' }); });
     });
diff --git a/test/visual.html b/test/visual.html
index 1d2cf15..f066a7b 100644
--- a/test/visual.html
+++ b/test/visual.html
@@ -80,7 +80,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">x_{very\ long\ thing}^2 + a_0 = 0</span></div>

 

 <h3>Redrawing</h3>

 <p>

@@ -190,6 +190,7 @@
 <tr><td><span class="mathquill-static-math">^{\frac{as}{ }df}</span><td><span>^{\frac{as}{ }df}</span>

 <tr><td><span class="mathquill-static-math">e^{i\pi}+1=0</span><td><span>e^{i\pi}+1=0</span>

 <tr><td><span class="mathquill-static-math">\sqrt[n]{1}</span><td><span>\sqrt[n]{1}</span>

+<tr><td><span class="mathquill-static-math">\sin ^2x+\sin ^2\left(x\right)+\sin ^2(x)</span></td><td><span>\sin ^2x+\sin ^2\left(x\right)+\sin ^2(x)</span>

 <tr><td><span class="mathquill-static-math">12a\sin b</span></td><td><span>12a\sin b</span>

 <tr><td><span class="mathquill-static-math">1a^2 \sin b</span></td><td><span>1a^2 \sin b</span>

 <tr><td><span class="mathquill-static-math">a + \sin b</span></td><td><span>a + \sin b</span>

@@ -301,7 +302,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