Merge pull request #696 from CarnegieLearningWeb/feature-configInnerFields

Configure inner math field with options
diff --git a/circle.yml b/circle.yml
index 0a162f0..f93227b 100644
--- a/circle.yml
+++ b/circle.yml
@@ -35,9 +35,6 @@
   cache_directories:
     - ~/sauce-connect
   pre:
-    # imagemagick is installed to give us access to the
-    # `convert` tool to stitch together the screenshots.
-    - sudo apt-get update; sudo apt-get install imagemagick
     - "test $SAUCE_USERNAME && test $SAUCE_ACCESS_KEY
        # Sauce Labs credentials required. Sign up here: https://saucelabs.com/opensauce/"
     - ? |-
diff --git a/docs/Api_Methods.md b/docs/Api_Methods.md
index 8271945..b7db00c 100644
--- a/docs/Api_Methods.md
+++ b/docs/Api_Methods.md
@@ -45,7 +45,7 @@
 ```html
 <span id="fill-in-the-blank">\sqrt{ \MathQuillMathField{x}^2 + \MathQuillMathField{y}^2 }</span>
 <script>
-  var fillInTheBlank = MQ.StaticMath(document.getElementById('#fill-in-the-blank'));
+  var fillInTheBlank = MQ.StaticMath(document.getElementById('fill-in-the-blank'));
   fillInTheBlank.innerFields[0].latex() // => 'x'
   fillInTheBlank.innerFields[1].latex() // => 'y'
 </script>
diff --git a/src/commands/math/basicSymbols.js b/src/commands/math/basicSymbols.js
index a8578ae..7f44356 100644
--- a/src/commands/math/basicSymbols.js
+++ b/src/commands/math/basicSymbols.js
@@ -247,6 +247,7 @@
 LatexCmds[' '] = LatexCmds.space = bind(VanillaSymbol, '\\ ', '&nbsp;');
 
 LatexCmds["'"] = LatexCmds.prime = bind(VanillaSymbol, "'", '&prime;');
+LatexCmds['″'] = LatexCmds.dprime = bind(VanillaSymbol, '″', '&Prime;');
 
 LatexCmds.backslash = bind(VanillaSymbol,'\\backslash ','\\');
 if (!CharCmds['\\']) CharCmds['\\'] = LatexCmds.backslash;
@@ -421,12 +422,28 @@
   _.init = VanillaSymbol.prototype.init;
 
   _.contactWeld = _.siblingCreated = _.siblingDeleted = function(opts, dir) {
+    function determineOpClassType(node) {
+      if (node[L]) {
+        // 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
+        if (node[L] instanceof BinaryOperator || /^[,;:\(\[]$/.test(node[L].ctrlSeq)) {
+          return '';
+        }
+      } else if (node.parent && node.parent.parent && node.parent.parent.isStyleBlock()) {
+        //if we are in a style block at the leftmost edge, determine unary/binary based on
+        //the style block
+        //this allows style blocks to be transparent for unary/binary purposes
+        return determineOpClassType(node.parent.parent);
+      } else {
+        return '';
+      }
+
+      return 'mq-binary-operator';
+    };
+    
     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 || /^[,;:\(\[]$/.test(this[L].ctrlSeq) ? '' : 'mq-binary-operator');
+    this.jQ[0].className = determineOpClassType(this);
     return this;
   };
 });
diff --git a/src/commands/math/commands.js b/src/commands/math/commands.js
index 8f12745..9fa95e1 100644
--- a/src/commands/math/commands.js
+++ b/src/commands/math/commands.js
@@ -114,6 +114,9 @@
       })
     ;
   };
+  _.isStyleBlock = function() {
+    return true;
+  };
 });
 
 // Very similar to the \textcolor command, but will add the given CSS class.
@@ -133,6 +136,9 @@
       })
     ;
   };
+  _.isStyleBlock = function() {
+    return true;
+  };
 });
 
 var SupSub = P(MathCommand, function(_, super_) {
diff --git a/src/css/textarea.less b/src/css/textarea.less
index 1484fa7..4da9bdb 100644
--- a/src/css/textarea.less
+++ b/src/css/textarea.less
@@ -20,5 +20,10 @@
 
     width: 1px; // don't "stick out" invisibly from a math field,
     height: 1px; // can affect ancestor's .scroll{Width,Height}
+
+    // Needed to fix a Safari 10 bug where box-sizing: border-box is
+    // preventing text from being copied.
+    // https://github.com/mathquill/mathquill/issues/686
+    box-sizing: content-box;
   }
 }
diff --git a/src/publicapi.js b/src/publicapi.js
index 2ccdeb2..78ce1b4 100644
--- a/src/publicapi.js
+++ b/src/publicapi.js
@@ -77,6 +77,7 @@
 
   MQ.L = L;
   MQ.R = R;
+  MQ.saneKeyboardEvents = saneKeyboardEvents;
 
   function config(currentOptions, newOptions) {
     if (newOptions && newOptions.handlers) {
diff --git a/src/services/keystroke.js b/src/services/keystroke.js
index f3ec668..fc3fc8e 100644
--- a/src/services/keystroke.js
+++ b/src/services/keystroke.js
@@ -241,7 +241,7 @@
   _.ctrlDeleteDir = function(dir) {
     prayDirection(dir);
     var cursor = this.cursor;
-    if (!cursor[L] || cursor.selection) return ctrlr.deleteDir();
+    if (!cursor[L] || cursor.selection) return this.deleteDir();
 
     this.notify('edit');
     Fragment(cursor.parent.ends[L], cursor[L]).remove();
diff --git a/src/services/saneKeyboardEvents.util.js b/src/services/saneKeyboardEvents.util.js
index cdb9686..e6bfb4f 100644
--- a/src/services/saneKeyboardEvents.util.js
+++ b/src/services/saneKeyboardEvents.util.js
@@ -95,8 +95,8 @@
     var textarea = jQuery(el);
     var target = jQuery(handlers.container || textarea);
 
-    // checkTextareaFor() is called after keypress or paste events to
-    // say "Hey, I think something was just typed" or "pasted" (resp.),
+    // checkTextareaFor() is called after key or clipboard events to
+    // say "Hey, I think something was just typed" or "pasted" etc,
     // so that at all subsequent opportune times (next event or timeout),
     // will check for expected typed or pasted text.
     // Need to check repeatedly because #135: in Safari 5.1 (at least),
@@ -109,6 +109,13 @@
       clearTimeout(timeoutId);
       timeoutId = setTimeout(checker);
     }
+    function checkTextareaOnce(checker) {
+      checkTextareaFor(function(e) {
+        checkTextarea = noop;
+        clearTimeout(timeoutId);
+        checker(e);
+      });
+    }
     target.bind('keydown keypress input keyup focusout paste', function(e) { checkTextarea(e); });
 
 
@@ -148,12 +155,12 @@
       keydown = e;
       keypress = null;
 
-      if (shouldBeSelected) checkTextareaFor(function(e) {
+      if (shouldBeSelected) checkTextareaOnce(function(e) {
         if (!(e && e.type === 'focusout') && textarea[0].select) {
-          textarea[0].select(); // re-select textarea in case it's an unrecognized
+          // re-select textarea in case it's an unrecognized key that clears
+          // the selection, then never again, 'cos next thing might be blur
+          textarea[0].select();
         }
-        checkTextarea = noop; // key that clears the selection, then never
-        clearTimeout(timeoutId); // again, 'cos next thing might be blur
       });
 
       handleKey();
@@ -229,6 +236,8 @@
       keydown: onKeydown,
       keypress: onKeypress,
       focusout: onBlur,
+      cut: function() { checkTextareaOnce(function() { handlers.cut(); }); },
+      copy: function() { checkTextareaOnce(function() { handlers.copy(); }); },
       paste: onPaste
     });
 
diff --git a/src/services/textarea.js b/src/services/textarea.js
index dd2d2de..eaed10c 100644
--- a/src/services/textarea.js
+++ b/src/services/textarea.js
@@ -18,7 +18,6 @@
 
     var ctrlr = this;
     ctrlr.cursor.selectionChanged = function() { ctrlr.selectionChanged(); };
-    ctrlr.container.bind('copy', function() { ctrlr.setTextareaSelection(); });
   };
   _.selectionChanged = function() {
     var ctrlr = this;
@@ -52,6 +51,7 @@
     this.container.prepend('<span class="mq-selectable">$'+ctrlr.exportLatex()+'$</span>');
     ctrlr.blurred = true;
     textarea.bind('cut paste', false)
+    .bind('copy', function() { ctrlr.setTextareaSelection(); })
     .focus(function() { ctrlr.blurred = false; }).blur(function() {
       if (cursor.selection) cursor.selection.clear();
       setTimeout(detach); //detaching during blur explodes in WebKit
@@ -66,23 +66,13 @@
       if (text) textarea.select();
     };
   };
+  Options.p.substituteKeyboardEvents = saneKeyboardEvents;
   _.editablesTextareaEvents = function() {
-    var ctrlr = this, root = ctrlr.root, cursor = ctrlr.cursor,
-      textarea = ctrlr.textarea, textareaSpan = ctrlr.textareaSpan;
+    var ctrlr = this, textarea = ctrlr.textarea, textareaSpan = ctrlr.textareaSpan;
 
-    var keyboardEventsShim = saneKeyboardEvents(textarea, this);
+    var keyboardEventsShim = this.options.substituteKeyboardEvents(textarea, this);
     this.selectFn = function(text) { keyboardEventsShim.select(text); };
-
-    this.container.prepend(textareaSpan)
-    .on('cut', function(e) {
-      if (cursor.selection) {
-        setTimeout(function() {
-          ctrlr.notify('edit'); // deletes selection if present
-          cursor.parent.bubble('reflow');
-        });
-      }
-    });
-
+    this.container.prepend(textareaSpan);
     this.focusBlurEvents();
   };
   _.typedText = function(ch) {
@@ -91,6 +81,18 @@
     cursor.parent.write(cursor, ch);
     this.scrollHoriz();
   };
+  _.cut = function() {
+    var ctrlr = this, cursor = ctrlr.cursor;
+    if (cursor.selection) {
+      setTimeout(function() {
+        ctrlr.notify('edit'); // deletes selection if present
+        cursor.parent.bubble('reflow');
+      });
+    }
+  };
+  _.copy = function() {
+    this.setTextareaSelection();
+  };
   _.paste = function(text) {
     // TODO: document `statelessClipboard` config option in README, after
     // making it work like it should, that is, in both text and math mode
diff --git a/src/tree.js b/src/tree.js
index 649c517..335dafa 100644
--- a/src/tree.js
+++ b/src/tree.js
@@ -156,6 +156,10 @@
   _.isEmpty = function() {
     return this.ends[L] === 0 && this.ends[R] === 0;
   };
+  
+  _.isStyleBlock = function() {
+    return false;
+  };
 
   _.children = function() {
     return Fragment(this.ends[L], this.ends[R]);
diff --git a/test/unit/css.test.js b/test/unit/css.test.js
index 639cdf9..56e12d2 100644
--- a/test/unit/css.test.js
+++ b/test/unit/css.test.js
@@ -82,6 +82,37 @@
     $(mq.el()).remove();
   });
 
+  test('proper unary/binary within style block', function () {
+    var mq = MQ.MathField($('<span></span>').appendTo('#mock')[0]);
+    mq.latex('\\class{dummy}{-}2\\class{dummy}{+}4');
+    var spans = $(mq.el()).find('.mq-root-block').find('span');
+    assert.equal(spans.length, 6, '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, '\\class{dummy}{-}');
+    assertBinaryOperator(4, '\\class{dummy}{-}2\\class{dummy}{+}');
+
+    mq.latex('\\textcolor{red}{-}2\\textcolor{green}{+}4');
+    spans = $(mq.el()).find('.mq-root-block').find('span');
+    assert.equal(spans.length, 6, 'PlusMinus expression parsed incorrectly');
+
+    assertUnaryOperator(1, '\\textcolor{red}{-}');
+    assertBinaryOperator(4, '\\textcolor{red}{-}2\\textcolor{green}{+}');
+
+    //test recursive depths
+    mq.latex('\\textcolor{red}{\\class{dummy}{-}}2\\textcolor{green}{\\class{dummy}{+}}4');
+    spans = $(mq.el()).find('.mq-root-block').find('span');
+    assert.equal(spans.length, 8, 'PlusMinus expression parsed incorrectly');
+
+    assertUnaryOperator(2, '\\textcolor{red}{\\class{dummy}{-}}');
+    assertBinaryOperator(6, '\\textcolor{red}{\\class{dummy}{-}}2\\textcolor{green}{\\class{dummy}{+}}');
+
+    $(mq.el()).remove();
+  });
+
   test('operator name spacing e.g. sin x', function() {
     var mq = MathQuill.MathField($('<span></span>').appendTo(mock)[0]);
 
diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js
index 0542b80..e4fff58 100644
--- a/test/unit/publicapi.test.js
+++ b/test/unit/publicapi.test.js
@@ -799,6 +799,51 @@
     });
   });
 
+  suite('substituteKeyboardEvents', function() {
+    test('can intercept key events', function() {
+      var mq = MQ.MathField($('<span>').appendTo('#mock')[0], {
+        substituteKeyboardEvents: function(textarea, handlers) {
+          return MQ.saneKeyboardEvents(textarea, jQuery.extend({}, handlers, {
+            keystroke: function(_key, evt) {
+              key = _key;
+              return handlers.keystroke.apply(handlers, arguments);
+            }
+          }));
+        }
+      });
+      var key;
+
+      $(mq.el()).find('textarea').trigger({ type: 'keydown', which: '37' });
+      assert.equal(key, 'Left');
+
+      $(mq.el()).remove();
+    });
+    test('cut is async', function() {
+      var mq = MQ.MathField($('<span>').appendTo('#mock')[0], {
+        substituteKeyboardEvents: function(textarea, handlers) {
+          return MQ.saneKeyboardEvents(textarea, jQuery.extend({}, handlers, {
+            cut: function() {
+              count += 1;
+              return handlers.cut.apply(handlers, arguments);
+            }
+          }));
+        }
+      });
+      var count = 0;
+
+      $(mq.el()).find('textarea').trigger('cut');
+      assert.equal(count, 0);
+
+      $(mq.el()).find('textarea').trigger('input');
+      assert.equal(count, 1);
+
+      $(mq.el()).find('textarea').trigger('keyup');
+      assert.equal(count, 1);
+
+      $(mq.el()).remove();
+    });
+  });
+
   suite('clickAt', function() {
     test('inserts at coordinates', function() {
       // Insert filler so that the page is taller than the window so this test is deterministic
diff --git a/test/unit/saneKeyboardEvents.test.js b/test/unit/saneKeyboardEvents.test.js
index 158d425..fe59265 100644
--- a/test/unit/saneKeyboardEvents.test.js
+++ b/test/unit/saneKeyboardEvents.test.js
@@ -410,4 +410,17 @@
       el.trigger('input');
     });
   });
+
+  suite('copy', function() {
+    test('only runs handler once even if handler synchronously selects', function() {
+      // ...which MathQuill does and resulted in a stack overflow: https://git.io/vosm0
+      var shim = saneKeyboardEvents(el, {
+        copy: function() {
+          shim.select();
+        }
+      });
+
+      el.trigger('copy');
+    });
+  });
 });
diff --git a/test/visual.html b/test/visual.html
index a5918b5..91a9874 100644
--- a/test/visual.html
+++ b/test/visual.html
@@ -276,6 +276,12 @@
 

 <p>In Safari on iOS, this should be focusable but not bring up the on-screen keyboard; to test, try focusing anything else and confirm this blurs: <span id="no-kbd-math"></span> (confirmed working on iOS 6.1.3)</p>

 

+<h3>substituteKeyboardEvents</h3>

+

+<p>Should be able to prevent cut, typing, and pasting in this field: <span id="disable-typing">1+2+3</span></p>

+

+<p>Should wrap anything you type in '&lt;&gt;': <span id="wrap-typing">1+2+3</span></p>

+

 </div>

 <script type="text/javascript">

 window.onerror = function(err) {

@@ -446,6 +452,29 @@
     return $('<span tabindex=0 style="display:inline-block;width:1px;height:1px" />')[0];

   }

 });

+

+MQ.MathField($('#disable-typing')[0], {

+  substituteKeyboardEvents: function(textarea, handlers) {

+    return MQ.saneKeyboardEvents(textarea, $.extend({}, handlers, {

+      cut: $.noop,

+      paste: $.noop,

+      keystroke: $.noop,

+      typedText: $.noop

+    }));

+  }

+});

+

+MQ.MathField($('#wrap-typing')[0], {

+  substituteKeyboardEvents: function(textarea, handlers) {

+    return MQ.saneKeyboardEvents(textarea, $.extend({}, handlers, {

+      typedText: function (text) {

+        handlers.typedText('<');

+        handlers.typedText(text);

+        handlers.typedText('>');

+      }

+    }));

+  }

+});

 </script>

 </body>

 </html>