Bug 844716 - Enable keyboard Apps to get/set selection range of the input field. r=timdream
authorYuan Xulei <xyuan@mozilla.com>
Fri, 15 Mar 2013 08:28:51 -0400
changeset 124924 901a79e85148dc4dd921072683becaf477fb492a
parent 124923 92dfd74bb5149442e69f4a12a2f7fe52ef6e6e9f
child 124925 90b526ec24cb2c07bd713ef4dc8d3d88dde42aef
push id24678
push userryanvm@gmail.com
push dateFri, 15 Mar 2013 12:28:59 +0000
treeherdermozilla-inbound@e90fa0a09ac5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstimdream
bugs844716
milestone22.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 844716 - Enable keyboard Apps to get/set selection range of the input field. r=timdream
b2g/chrome/content/forms.js
b2g/components/Keyboard.jsm
b2g/components/MozKeyboard.js
b2g/components/b2g.idl
--- a/b2g/chrome/content/forms.js
+++ b/b2g/chrome/content/forms.js
@@ -180,28 +180,31 @@ let FormVisibility = {
 
 let FormAssistant = {
   init: function fa_init() {
     addEventListener("focus", this, true, false);
     addEventListener("blur", this, true, false);
     addEventListener("resize", this, true, false);
     addEventListener("submit", this, true, false);
     addEventListener("pagehide", this, true, false);
+    addEventListener("input", this, true, false);
+    addEventListener("keydown", this, true, false);
     addMessageListener("Forms:Select:Choice", this);
     addMessageListener("Forms:Input:Value", this);
     addMessageListener("Forms:Select:Blur", this);
+    addMessageListener("Forms:SetSelectionRange", this);
   },
 
   ignoredInputTypes: new Set([
     'button', 'file', 'checkbox', 'radio', 'reset', 'submit', 'image'
   ]),
 
   isKeyboardOpened: false,
-  selectionStart: 0,
-  selectionEnd: 0,
+  selectionStart: -1,
+  selectionEnd: -1,
   scrollIntoViewTimeout: null,
   _focusedElement: null,
   _documentEncoder: null,
 
   get focusedElement() {
     if (this._focusedElement && Cu.isDeadWrapper(this._focusedElement))
       this._focusedElement = null;
 
@@ -252,52 +255,57 @@ let FormAssistant = {
         }
 
         if (target instanceof HTMLDocument || target == content) {
           break;
         }
 
         if (isContentEditable(target)) {
           this.showKeyboard(this.getTopLevelEditable(target));
+          this.updateSelection();
           break;
         }
 
-        if (this.isFocusableElement(target))
+        if (this.isFocusableElement(target)) {
           this.showKeyboard(target);
+          this.updateSelection();
+        }
         break;
 
       case "pagehide":
         // We are only interested to the pagehide event from the root document.
         if (target && target != content.document) {
           break;
         }
         // fall through
       case "blur":
       case "submit":
-        if (this.focusedElement)
+        if (this.focusedElement) {
           this.hideKeyboard();
+          this.selectionStart = -1;
+          this.selectionEnd = -1;
+        }
         break;
 
       case 'mousedown':
         // We only listen for this event on the currently focused element.
         // When the mouse goes down, note the cursor/selection position
-        range = getSelectionRange(this.focusedElement);
-        this.selectionStart = range[0];
-        this.selectionEnd = range[1];
+        this.updateSelection();
         break;
 
       case 'mouseup':
         // 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.sendKeyboardState(this.focusedElement);
+          this.updateSelection();
         }
         break;
 
       case "resize":
         if (!this.isKeyboardOpened)
           return;
 
         if (this.scrollIntoViewTimeout) {
@@ -311,16 +319,29 @@ let FormAssistant = {
           this.scrollIntoViewTimeout = content.setTimeout(function () {
             this.scrollIntoViewTimeout = null;
             if (this.focusedElement && !FormVisibility.isVisible(this.focusedElement)) {
               this.focusedElement.scrollIntoView(false);
             }
           }.bind(this), RESIZE_SCROLL_DELAY);
         }
         break;
+
+      case "input":
+        // When the text content changes, notify the keyboard
+        this.updateSelection();
+        break;
+
+      case "keydown":
+        // We use 'setTimeout' to wait until the input element accomplishes the
+        // change in selection range
+        content.setTimeout(function() {
+          this.updateSelection();
+        }.bind(this), 0);
+        break;
     }
   },
 
   receiveMessage: function fa_receiveMessage(msg) {
     let target = this.focusedElement;
     if (!target) {
       return;
     }
@@ -361,16 +382,24 @@ let FormAssistant = {
           target.dispatchEvent(event);
         }
         break;
 
       case "Forms:Select:Blur": {
         this.setFocusedElement(null);
         break;
       }
+
+      case "Forms:SetSelectionRange":  {
+        let start = json.selectionStart;
+        let end =  json.selectionEnd;
+        setSelectionRange(target, start, end);
+        this.updateSelection();
+        break;
+      }
     }
   },
 
   showKeyboard: function fa_showKeyboard(target) {
     if (this.isKeyboardOpened)
       return;
 
     if (target instanceof HTMLOptionElement)
@@ -437,16 +466,29 @@ let FormAssistant = {
     // in gecko.
     let readonly = element.getAttribute("readonly");
     if (readonly) {
       return false;
     }
 
     sendAsyncMessage("Forms:Input", getJSON(element));
     return true;
+  },
+
+  // Notify when the selection range changes
+  updateSelection: function fa_updateSelection() {
+    let range =  getSelectionRange(this.focusedElement);
+    if (range[0] != this.selectionStart || range[1] != this.selectionEnd) {
+      this.selectionStart = range[0];
+      this.selectionEnd = range[1];
+      sendAsyncMessage("Forms:SelectionChange", {
+        selectionStart: range[0],
+        selectionEnd: range[1]
+      });
+    }
   }
 };
 
 FormAssistant.init();
 
 function isContentEditable(element) {
   if (element.isContentEditable || element.designMode == "on")
     return true;
@@ -593,22 +635,84 @@ function getSelectionRange(element) {
       element instanceof HTMLTextAreaElement) {
     // Get the selection range of <input> and <textarea> elements
     start = element.selectionStart;
     end = element.selectionEnd;
   } else if (isContentEditable(element)){
     // Get the selection range of contenteditable elements
     let win = element.ownerDocument.defaultView;
     let sel = win.getSelection();
+    start = getContentEditableSelectionStart(element, sel);
+    end = start + getContentEditableSelectionLength(element, sel);
+   }
+   return [start, end];
+ }
 
-    let range = win.document.createRange();
-    range.setStart(element, 0);
-    range.setEnd(sel.anchorNode, sel.anchorOffset);
-    let encoder = FormAssistant.documentEncoder;
+function getContentEditableSelectionStart(element, selection) {
+  let doc = element.ownerDocument;
+  let range = doc.createRange();
+  range.setStart(element, 0);
+  range.setEnd(selection.anchorNode, selection.anchorOffset);
+  let encoder = FormAssistant.documentEncoder;
+  encoder.setRange(range);
+  return encoder.encodeToString().length;
+}
+
+function getContentEditableSelectionLength(element, selection) {
+  let encoder = FormAssistant.documentEncoder;
+  encoder.setRange(selection.getRangeAt(0));
+  return encoder.encodeToString().length;
+}
+
+function setSelectionRange(element, start, end) {
+  let isPlainTextField = element instanceof HTMLInputElement ||
+                        element instanceof HTMLTextAreaElement;
+
+  // Check the parameters
+
+  if (!isPlainTextField && !isContentEditable(element)) {
+    // Skip HTMLOptionElement and HTMLSelectElement elements, as they don't
+    // support the operation of setSelectionRange
+    return;
+  }
 
-    encoder.setRange(range);
-    start = encoder.encodeToString().length;
+  let text = isPlainTextField ? element.value : getContentEditableText(element);
+  let length = text.length;
+  if (start < 0) {
+    start = 0;
+  }
+  if (end > length) {
+    end = length;
+  }
+  if (start > end) {
+    start = end;
+  }
+
+  if (isPlainTextField) {
+    // Set the selection range of <input> and <textarea> elements
+    element.setSelectionRange(start, end, "forward");
+  } else {
+    // set the selection range of contenteditable elements
+    let win = element.ownerDocument.defaultView;
+    let sel = win.getSelection();
 
-    encoder.setRange(sel.getRangeAt(0));
-    end = start + encoder.encodeToString().length;
+    // Move the caret to the start position
+    sel.collapse(element, 0);
+    for (let i = 0; i < start; i++) {
+      sel.modify("move", "forward", "character");
+    }
+
+    while (getContentEditableSelectionStart(element, sel) < start) {
+      sel.modify("move", "forward", "character");
+    }
+
+    // Extend the selection to the end position
+    for (let i = start; i < end; i++) {
+      sel.modify("extend", "forward", "character");
+    }
+
+    let selectionLength = end - start;
+    while (getContentEditableSelectionLength(element, sel) < selectionLength) {
+      sel.modify("extend", "forward", "character");
+    }
   }
-  return [start, end];
 }
+
--- a/b2g/components/Keyboard.jsm
+++ b/b2g/components/Keyboard.jsm
@@ -15,17 +15,18 @@ Cu.import('resource://gre/modules/Servic
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
   "@mozilla.org/parentprocessmessagemanager;1", "nsIMessageBroadcaster");
 
 let Keyboard = {
   _messageManager: null,
   _messageNames: [
-    'SetValue', 'RemoveFocus', 'SetSelectedOption', 'SetSelectedOptions'
+    'SetValue', 'RemoveFocus', 'SetSelectedOption', 'SetSelectedOptions',
+    'SetSelectionRange'
   ],
 
   get messageManager() {
     if (this._messageManager && !Cu.isDeadWrapper(this._messageManager))
       return this._messageManager;
 
     throw Error('no message manager set');
   },
@@ -41,32 +42,33 @@ let Keyboard = {
     for (let name of this._messageNames)
       ppmm.addMessageListener('Keyboard:' + name, this);
   },
 
   observe: function keyboardObserve(subject, topic, data) {
     let frameLoader = subject.QueryInterface(Ci.nsIFrameLoader);
     let mm = frameLoader.messageManager;
     mm.addMessageListener('Forms:Input', this);
+    mm.addMessageListener('Forms:SelectionChange', this);
 
     // When not running apps OOP, we need to load forms.js here since this
     // won't happen from dom/ipc/preload.js
     try {
        if (Services.prefs.getBoolPref("dom.ipc.tabs.disabled") === true) {
          mm.loadFrameScript(kFormsFrameScript, true);
        }
      } catch (e) {
        dump('Error loading ' + kFormsFrameScript + ' as frame script: ' + e + '\n');
      }
   },
 
   receiveMessage: function keyboardReceiveMessage(msg) {
     // If we get a 'Keyboard:XXX' message, check that the sender has the
     // keyboard permission.
-    if (msg.name != 'Forms:Input') {
+    if (msg.name.indexOf("Keyboard:") != -1) {
       let mm;
       try {
         mm = msg.target.QueryInterface(Ci.nsIFrameLoaderOwner)
                        .frameLoader.messageManager;
       } catch(e) {
         mm = msg.target;
       }
 
@@ -82,46 +84,63 @@ let Keyboard = {
         return;
       }
     }
 
     switch (msg.name) {
       case 'Forms:Input':
         this.handleFormsInput(msg);
         break;
+      case 'Forms:SelectionChange':
+        this.handleFormsSelectionChange(msg);
+        break;
       case 'Keyboard:SetValue':
         this.setValue(msg);
         break;
       case 'Keyboard:RemoveFocus':
         this.removeFocus();
         break;
       case 'Keyboard:SetSelectedOption':
         this.setSelectedOption(msg);
         break;
       case 'Keyboard:SetSelectedOptions':
         this.setSelectedOption(msg);
         break;
+      case 'Keyboard:SetSelectionRange':
+        this.setSelectionRange(msg);
+        break;
     }
   },
 
   handleFormsInput: function keyboardHandleFormsInput(msg) {
     this.messageManager = msg.target.QueryInterface(Ci.nsIFrameLoaderOwner)
                              .frameLoader.messageManager;
 
     ppmm.broadcastAsyncMessage('Keyboard:FocusChange', msg.data);
   },
 
+  handleFormsSelectionChange: function keyboardHandleFormsSelectionChange(msg) {
+    this.messageManager = msg.target.QueryInterface(Ci.nsIFrameLoaderOwner)
+                             .frameLoader.messageManager;
+
+    ppmm.broadcastAsyncMessage('Keyboard:SelectionChange', msg.data);
+  },
+
   setSelectedOption: function keyboardSetSelectedOption(msg) {
     this.messageManager.sendAsyncMessage('Forms:Select:Choice', msg.data);
   },
 
   setSelectedOptions: function keyboardSetSelectedOptions(msg) {
     this.messageManager.sendAsyncMessage('Forms:Select:Choice', msg.data);
   },
 
+  setSelectionRange: function keyboardSetSelectionRange(msg) {
+    this.messageManager.sendAsyncMessage('Forms:SetSelectionRange', msg.data);
+  },
+
   setValue: function keyboardSetValue(msg) {
     this.messageManager.sendAsyncMessage('Forms:Input:Value', msg.data);
   },
 
   removeFocus: function keyboardRemoveFocus() {
     this.messageManager.sendAsyncMessage('Forms:Select:Blur', {});
   }
 };
--- a/b2g/components/MozKeyboard.js
+++ b/b2g/components/MozKeyboard.js
@@ -42,31 +42,37 @@ MozKeyboard.prototype = {
     if (perm != Ci.nsIPermissionManager.ALLOW_ACTION) {
       dump("No permission to use the keyboard API for " +
            principal.origin + "\n");
       return null;
     }
 
     Services.obs.addObserver(this, "inner-window-destroyed", false);
     cpmm.addMessageListener('Keyboard:FocusChange', this);
+    cpmm.addMessageListener('Keyboard:SelectionChange', this);
 
     this._window = win;
     this._utils = win.QueryInterface(Ci.nsIInterfaceRequestor)
                      .getInterface(Ci.nsIDOMWindowUtils);
     this.innerWindowID = this._utils.currentInnerWindowID;
     this._focusHandler = null;
+    this._selectionHandler = null;
+    this._selectionStart = -1;
+    this._selectionEnd = -1;
   },
 
   uninit: function mozKeyboardUninit() {
     Services.obs.removeObserver(this, "inner-window-destroyed");
     cpmm.removeMessageListener('Keyboard:FocusChange', this);
+    cpmm.removeMessageListener('Keyboard:SelectionChange', this);
 
     this._window = null;
     this._utils = null;
     this._focusHandler = null;
+    this._selectionHandler = null;
   },
 
   sendKey: function mozKeyboardSendKey(keyCode, charCode) {
     charCode = (charCode == undefined) ? keyCode : charCode;
     ["keydown", "keypress", "keyup"].forEach((function sendKey(type) {
       this._utils.sendKeyEvent(type, keyCode, charCode, null);
     }).bind(this));
   },
@@ -84,43 +90,89 @@ MozKeyboard.prototype = {
   },
 
   setSelectedOptions: function mozKeyboardSetSelectedOptions(indexes) {
     cpmm.sendAsyncMessage('Keyboard:SetSelectedOptions', {
       'indexes': indexes
     });
   },
 
+  set onselectionchange(val) {
+    this._selectionHandler = val;
+  },
+
+  get onselectionchange() {
+    return this._selectionHandler;
+  },
+
+  get selectionStart() {
+    return this._selectionStart;
+  },
+
+  get selectionEnd() {
+    return this._selectionEnd;
+  },
+
+  setSelectionRange: function mozKeyboardSetSelectionRange(start, end) {
+    cpmm.sendAsyncMessage('Keyboard:SetSelectionRange', {
+      'selectionStart': start,
+      'selectionEnd': end
+    });
+  },
+
   removeFocus: function mozKeyboardRemoveFocus() {
     cpmm.sendAsyncMessage('Keyboard:RemoveFocus', {});
   },
 
   set onfocuschange(val) {
     this._focusHandler = val;
   },
 
   get onfocuschange() {
     return this._focusHandler;
   },
 
   receiveMessage: function mozKeyboardReceiveMessage(msg) {
-    let handler = this._focusHandler;
-    if (!handler || !(handler instanceof Ci.nsIDOMEventListener))
-      return;
+    if (msg.name == "Keyboard:FocusChange") {
+       let msgJson = msg.json;
+       if (msgJson.type != "blur") {
+         this._selectionStart = msgJson.selectionStart;
+         this._selectionEnd = msgJson.selectionEnd;
+       } else {
+         this._selectionStart = 0;
+         this._selectionEnd = 0;
+       }
+
+      let handler = this._focusHandler;
+      if (!handler || !(handler instanceof Ci.nsIDOMEventListener))
+        return;
+
+      let detail = {
+        "detail": msgJson
+      };
 
-    let detail = {
-      "detail": msg.json
-    };
+      let evt = new this._window.CustomEvent("focuschanged",
+          ObjectWrapper.wrap(detail, this._window));
+      handler.handleEvent(evt);
+    } else if (msg.name == "Keyboard:SelectionChange") {
+      let msgJson = msg.json;
+
+      this._selectionStart = msgJson.selectionStart;
+      this._selectionEnd = msgJson.selectionEnd;
 
-    let evt = new this._window.CustomEvent("focuschanged",
-                                           ObjectWrapper.wrap(detail, this._window));
-    handler.handleEvent(evt);
+      let handler = this._selectionHandler;
+      if (!handler || !(handler instanceof Ci.nsIDOMEventListener))
+        return;
+
+      let evt = new this._window.CustomEvent("selectionchange",
+          ObjectWrapper.wrap({}, this._window));
+      handler.handleEvent(evt);
+    }
   },
 
   observe: function mozKeyboardObserve(subject, topic, data) {
     let wId = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
     if (wId == this.innerWindowID)
       this.uninit();
   }
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([MozKeyboard]);
-
--- a/b2g/components/b2g.idl
+++ b/b2g/components/b2g.idl
@@ -35,9 +35,30 @@ interface nsIB2GKeyboard : nsISupports
   // list (type=month) or a widget (type=date, time, etc.).
   // If the value passed in parameter isn't valid (in the term of HTML5
   // Forms Validation), the value will simply be ignored by the element. 
   void setValue(in jsval value);
 
   void removeFocus();
 
   attribute nsIDOMEventListener onfocuschange;
+
+  // Fires when user moves the cursor, changes the selection, or alters the
+  // composing text length
+  attribute nsIDOMEventListener onselectionchange;
+
+  // The start position of the selection.
+  readonly attribute long selectionStart;
+
+  // The stop position of the selection.
+  readonly attribute long selectionEnd;
+
+  /*
+   * Set the selection range of the the editable text.
+   *
+   * @param start The beginning of the selected text.
+   * @param end The end of the selected text.
+   *
+   * Note that the start position should be less or equal to the end position.
+   * To move the cursor, set the start and end position to the same value.
+   */
+  void setSelectionRange(in long start, in long end);
 };