Bug 1026997 - Use nsISelectionPrivate to track selection changes in forms.js. r=yxl
authorJan Jongboom <janjongboom@gmail.com>
Wed, 13 Aug 2014 02:12:00 -0400
changeset 199861 b615a6a84a9c3d2a84d6c9b7d1b10e2cdb185634
parent 199860 c19e057a25323a1d28838b2be8890cde96071424
child 199862 0a071f841a357860655ca80d1496cecccdc44e36
push id47750
push userryanvm@gmail.com
push dateFri, 15 Aug 2014 21:04:12 +0000
treeherdermozilla-inbound@baea646f5a80 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersyxl
bugs1026997
milestone34.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1026997 - Use nsISelectionPrivate to track selection changes in forms.js. r=yxl
dom/inputmethod/MozKeyboard.js
dom/inputmethod/forms.js
dom/inputmethod/mochitest/mochitest.ini
dom/inputmethod/mochitest/test_bug1026997.html
--- a/dom/inputmethod/MozKeyboard.js
+++ b/dom/inputmethod/MozKeyboard.js
@@ -447,16 +447,21 @@ MozInputContext.prototype = {
 
     let json = msg.json;
     let resolver = this.takePromiseResolver(json.requestId);
 
     if (!resolver) {
       return;
     }
 
+    // Update context first before resolving promise to avoid race condition
+    if (json.selectioninfo) {
+      this.updateSelectionContext(json.selectioninfo);
+    }
+
     switch (msg.name) {
       case "Keyboard:SendKey:Result:OK":
         resolver.resolve();
         break;
       case "Keyboard:SendKey:Result:Error":
         resolver.reject(json.error);
         break;
       case "Keyboard:GetText:Result:OK":
--- a/dom/inputmethod/forms.js
+++ b/dom/inputmethod/forms.js
@@ -219,16 +219,17 @@ let FormAssistant = {
   textAfterCursor: "",
   scrollIntoViewTimeout: null,
   _focusedElement: null,
   _focusCounter: 0, // up one for every time we focus a new element
   _observer: null,
   _documentEncoder: null,
   _editor: null,
   _editing: false,
+  _selectionPrivate: null,
 
   get focusedElement() {
     if (this._focusedElement && Cu.isDeadWrapper(this._focusedElement))
       this._focusedElement = null;
 
     return this._focusedElement;
   },
 
@@ -239,52 +240,58 @@ let FormAssistant = {
 
   setFocusedElement: function fa_setFocusedElement(element) {
     let self = this;
 
     if (element === this.focusedElement)
       return;
 
     if (this.focusedElement) {
-      this.focusedElement.removeEventListener('mousedown', this);
-      this.focusedElement.removeEventListener('mouseup', this);
       this.focusedElement.removeEventListener('compositionend', this);
       if (this._observer) {
         this._observer.disconnect();
         this._observer = null;
       }
       if (!element) {
         this.focusedElement.blur();
       }
+      if (this._selectionPrivate) {
+        this._selectionPrivate.removeSelectionListener(this);
+        this._selectionPrivate = null;
+      }
     }
 
     this._documentEncoder = null;
     if (this._editor) {
       // When the nsIFrame of the input element is reconstructed by
       // CSS restyling, the editor observers are removed. Catch
       // [nsIEditor.removeEditorObserver] failure exception if that
       // happens.
       try {
         this._editor.removeEditorObserver(this);
       } catch (e) {}
       this._editor = null;
     }
 
     if (element) {
-      element.addEventListener('mousedown', this);
-      element.addEventListener('mouseup', this);
       element.addEventListener('compositionend', this);
       if (isContentEditable(element)) {
         this._documentEncoder = getDocumentEncoder(element);
       }
       this._editor = getPlaintextEditor(element);
       if (this._editor) {
         // Add a nsIEditorObserver to monitor the text content of the focused
         // element.
         this._editor.addEditorObserver(this);
+
+        let selection = this._editor.selection;
+        if (selection) {
+          this._selectionPrivate = selection.QueryInterface(Ci.nsISelectionPrivate);
+          this._selectionPrivate.addSelectionListener(this);
+        }
       }
 
       // If our focusedElement is removed from DOM we want to handle it properly
       let MutationObserver = element.ownerDocument.defaultView.MutationObserver;
       this._observer = new MutationObserver(function(mutations) {
         var del = [].some.call(mutations, function(m) {
           return [].some.call(m.removedNodes, function(n) {
             return n.contains(element);
@@ -300,16 +307,20 @@ let FormAssistant = {
         childList: true,
         subtree: true
       });
     }
 
     this.focusedElement = element;
   },
 
+  notifySelectionChanged: function(aDocument, aSelection, aReason) {
+    this.updateSelection();
+  },
+
   get documentEncoder() {
     return this._documentEncoder;
   },
 
   // Get the nsIPlaintextEditor object of current input field.
   get editor() {
     return this._editor;
   },
@@ -371,42 +382,16 @@ let FormAssistant = {
       case "submit":
         if (this.focusedElement) {
           this.hideKeyboard();
           this.selectionStart = -1;
           this.selectionEnd = -1;
         }
         break;
 
-      case 'mousedown':
-         if (!this.focusedElement) {
-          break;
-        }
-
-        // We only listen for this event on the currently focused element.
-        // When the mouse goes down, note the cursor/selection position
-        this.updateSelection();
-        break;
-
-      case 'mouseup':
-        if (!this.focusedElement) {
-          break;
-        }
-
-        // We only listen for this event on the currently focused element.
-        // When the mouse goes up, see if the cursor has moved (or the
-        // selection changed) since the mouse went down. If it has, we
-        // need to tell the keyboard about it
-        range = getSelectionRange(this.focusedElement);
-        if (range[0] !== this.selectionStart ||
-            range[1] !== this.selectionEnd) {
-          this.updateSelection();
-        }
-        break;
-
       case "resize":
         if (!this.isKeyboardOpened)
           return;
 
         if (this.scrollIntoViewTimeout) {
           content.clearTimeout(this.scrollIntoViewTimeout);
           this.scrollIntoViewTimeout = null;
         }
@@ -418,44 +403,30 @@ let FormAssistant = {
             this.scrollIntoViewTimeout = null;
             if (this.focusedElement && !FormVisibility.isVisible(this.focusedElement)) {
               scrollSelectionOrElementIntoView(this.focusedElement);
             }
           }.bind(this), RESIZE_SCROLL_DELAY);
         }
         break;
 
-      case "input":
-        if (this.focusedElement) {
-          // When the text content changes, notify the keyboard
-          this.updateSelection();
-        }
-        break;
-
       case "keydown":
         if (!this.focusedElement) {
           break;
         }
 
         CompositionManager.endComposition('');
-
-        // We use 'setTimeout' to wait until the input element accomplishes the
-        // change in selection range.
-        content.setTimeout(function() {
-          this.updateSelection();
-        }.bind(this), 0);
         break;
 
       case "keyup":
         if (!this.focusedElement) {
           break;
         }
 
         CompositionManager.endComposition('');
-
         break;
 
       case "compositionend":
         if (!this.focusedElement) {
           break;
         }
 
         CompositionManager.onCompositionEnd();
@@ -521,17 +492,18 @@ let FormAssistant = {
           domWindowUtils.sendKeyEvent('keyup', json.keyCode,
                                       json.charCode, json.modifiers, flags);
         }
 
         this._editing = false;
 
         if (json.requestId && doKeypress) {
           sendAsyncMessage("Forms:SendKey:Result:OK", {
-            requestId: json.requestId
+            requestId: json.requestId,
+            selectioninfo: this.getSelectionInfo()
           });
         }
         else if (json.requestId && !doKeypress) {
           sendAsyncMessage("Forms:SendKey:Result:Error", {
             requestId: json.requestId,
             error: "Keydown event got canceled"
           });
         }
@@ -579,18 +551,16 @@ let FormAssistant = {
             sendAsyncMessage("Forms:SetSelectionRange:Result:Error", {
               requestId: json.requestId,
               error: "failed"
             });
           }
           break;
         }
 
-        this.updateSelection();
-
         if (json.requestId) {
           sendAsyncMessage("Forms:SetSelectionRange:Result:OK", {
             requestId: json.requestId,
             selectioninfo: this.getSelectionInfo()
           });
         }
         break;
       }
@@ -647,24 +617,26 @@ let FormAssistant = {
         break;
       }
 
       case "Forms:SetComposition": {
         CompositionManager.setComposition(target, json.text, json.cursor,
                                           json.clauses);
         sendAsyncMessage("Forms:SetComposition:Result:OK", {
           requestId: json.requestId,
+          selectioninfo: this.getSelectionInfo()
         });
         break;
       }
 
       case "Forms:EndComposition": {
         CompositionManager.endComposition(json.text);
         sendAsyncMessage("Forms:EndComposition:Result:OK", {
           requestId: json.requestId,
+          selectioninfo: this.getSelectionInfo()
         });
         break;
       }
     }
     this._editing = false;
 
   },
 
@@ -753,25 +725,39 @@ let FormAssistant = {
       selectionStart: range[0],
       selectionEnd: range[1],
       textBeforeCursor: textAround.before,
       textAfterCursor: textAround.after,
       changed: changed
     };
   },
 
+  _selectionTimeout: null,
+
   // Notify when the selection range changes
   updateSelection: function fa_updateSelection() {
-    if (!this.focusedElement) {
-      return;
+    // A call to setSelectionRange on input field causes 2 selection changes
+    // one to [0,0] and one to actual value. Both are sent in same tick.
+    // Prevent firing two events in that scenario, always only use the last 1.
+    //
+    // It is also a workaround for Bug 1053048, which prevents 
+    // getSelectionInfo() accessing selectionStart or selectionEnd in the
+    // callback function of nsISelectionListener::NotifySelectionChanged().
+    if (this._selectionTimeout) {
+      content.clearTimeout(this._selectionTimeout);
     }
-    let selectionInfo = this.getSelectionInfo();
-    if (selectionInfo.changed) {
-      sendAsyncMessage("Forms:SelectionChange", this.getSelectionInfo());
-    }
+    this._selectionTimeout = content.setTimeout(function() {
+      if (!this.focusedElement) {
+        return;
+      }
+      let selectionInfo = this.getSelectionInfo();
+      if (selectionInfo.changed) {
+        sendAsyncMessage("Forms:SelectionChange", selectionInfo);
+      }
+    }.bind(this), 0);
   }
 };
 
 FormAssistant.init();
 
 function isContentEditable(element) {
   if (!element) {
     return false;
--- a/dom/inputmethod/mochitest/mochitest.ini
+++ b/dom/inputmethod/mochitest/mochitest.ini
@@ -10,11 +10,12 @@ support-files =
   file_test_sms_app.html
 
 [test_basic.html]
 [test_bug944397.html]
 [test_bug949059.html]
 [test_bug953044.html]
 [test_bug960946.html]
 [test_bug978918.html]
+[test_bug1026997.html]
 [test_bug1043828.html]
 [test_delete_focused_element.html]
 [test_sendkey_cancel.html]
new file mode 100644
--- /dev/null
+++ b/dom/inputmethod/mochitest/test_bug1026997.html
@@ -0,0 +1,101 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1026997
+-->
+<head>
+  <title>SelectionChange on InputMethod API.</title>
+  <script type="application/javascript;version=1.7" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript;version=1.7" src="inputmethod_common.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1026997">Mozilla Bug 1026997</a>
+<p id="display"></p>
+<pre id="test">
+<script class="testbody" type="application/javascript;version=1.7">
+
+inputmethod_setup(function() {
+  runTest();
+});
+
+// The frame script running in file_test_app.html.
+function appFrameScript() {
+  let input = content.document.getElementById('test-input');
+
+  input.focus();
+
+  function next(start, end) {
+    input.setSelectionRange(start, end);
+  }
+
+  addMessageListener("test:KeyBoard:nextSelection", function(event) {
+    let json = event.json;
+    next(json[0], json[1]);
+  });
+}
+
+function runTest() {
+  let actions = [
+    [0, 4],
+    [1, 1],
+    [3, 3],
+    [2, 3]
+  ];
+
+  let counter = 0;
+  let mm = null;
+  let ic = null;
+
+  let im = navigator.mozInputMethod;
+  im.oninputcontextchange = function() {
+    ok(true, 'inputcontextchange event was fired.');
+    im.oninputcontextchange = null;
+
+    ic = im.inputcontext;
+    if (!ic) {
+      ok(false, 'Should have a non-null inputcontext.');
+      inputmethod_cleanup();
+      return;
+    }
+
+    ic.onselectionchange = function() {
+      is(ic.selectionStart, actions[counter][0], "start");
+      is(ic.selectionEnd, actions[counter][1], "end");
+
+      if (++counter === actions.length) {
+        inputmethod_cleanup();
+        return;
+      }
+
+      next();
+    };
+
+    next();
+  };
+
+  // Set current page as an input method.
+  SpecialPowers.wrap(im).setActive(true);
+
+  // Create an app frame to recieve keyboard inputs.
+  let app = document.createElement('iframe');
+  app.src = 'file_test_app.html';
+  app.setAttribute('mozbrowser', true);
+  document.body.appendChild(app);
+  app.addEventListener('mozbrowserloadend', function() {
+    mm = SpecialPowers.getBrowserFrameMessageManager(app);
+    mm.loadFrameScript('data:,(' + appFrameScript.toString() + ')();', false);
+    next();
+  });
+
+  function next() {
+    if (ic && mm) {
+      mm.sendAsyncMessage('test:KeyBoard:nextSelection', actions[counter]);
+    }
+  }
+}
+</script>
+</pre>
+</body>
+</html>
+