Bug 696512 - Detect elements near taps to click on. r=mbrubeck
authorWes Johnston <wjohnston@mozilla.com
Fri, 28 Oct 2011 17:10:31 -0700
changeset 83321 8447eaf8f0f86e2277c9a9e47c1bd958b10e5135
parent 83320 a9fb813f816effdf27596a7ebb091d4375166a6b
child 83322 5860a54b71993f080493c8691cf07cb40a24e571
push id519
push userakeybl@mozilla.com
push dateWed, 01 Feb 2012 00:38:35 +0000
treeherdermozilla-beta@788ea1ef610b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmbrubeck
bugs696512
milestone10.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 696512 - Detect elements near taps to click on. r=mbrubeck
mobile/chrome/content/browser.js
--- a/mobile/chrome/content/browser.js
+++ b/mobile/chrome/content/browser.js
@@ -887,17 +887,25 @@ var BrowserEventHandler = {
         break;
       }
 
       case "click":
         if (this.blockClick) {
           aEvent.stopPropagation();
           aEvent.preventDefault();
         } else {
-          FormAssistant.handleClick(aEvent);
+          let closest = ElementTouchHelper.elementFromPoint(BrowserApp.selectedBrowser.contentWindow, aEvent.clientX, aEvent.clientY);
+          if (closest) {
+            aEvent.stopPropagation();
+            aEvent.preventDefault();
+            this._sendMouseEvent("mousedown", closest, aEvent.clientX, aEvent.clientY);
+            this._sendMouseEvent("mouseup", closest, aEvent.clientX, aEvent.clientY);
+          } else {
+            FormAssistant.handleClick(aEvent);
+          }
         }
         break;
 
       case "mousedown":
         this.startX = aEvent.clientX;
         this.startY = aEvent.clientY;
         this.blockClick = false;
 
@@ -1210,16 +1218,51 @@ var BrowserEventHandler = {
   _updateLastPosition: function(x, y, dx, dy) {
     this.lastX = x;
     this.lastY = y;
     this.lastTime = Date.now();
 
     this.motionBuffer.push({ dx: dx, dy: dy, time: this.lastTime });
   },
 
+  _sendMouseEvent: function _sendMouseEvent(aName, aElement, aX, aY, aButton) {
+    // the element can be out of the aX/aY point because of the touch radius
+    // if outside, we gracefully move the touch point to the center of the element
+    if (!(aElement instanceof HTMLHtmlElement)) {
+      let isTouchClick = true;
+      let rects = ElementTouchHelper.getContentClientRects(aElement);
+      for (let i = 0; i < rects.length; i++) {
+        let rect = rects[i];
+        // We might be able to deal with fractional pixels, but mouse events won't.
+        // Deflate the bounds in by 1 pixel to deal with any fractional scroll offset issues.
+        let inBounds = 
+          (aX > rect.left + 1 && aX < (rect.left + rect.width - 1)) &&
+          (aY > rect.top + 1 && aY < (rect.top + rect.height - 1));
+        if (inBounds) {
+          isTouchClick = false;
+          break;
+        }
+      }
+
+      if (isTouchClick) {
+        let rect = {x: rects[0].left, y: rects[0].top, w: rects[0].width, h: rects[0].height};
+        if (rect.w == 0 && rect.h == 0)
+          return;
+
+        let point = { x: rect.x + rect.w/2, y: rect.y + rect.h/2 };
+        aX = point.x;
+        aY = point.y;
+      }
+    }
+
+    let cwu = aElement.ownerDocument.defaultView.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+    aButton = aButton || 0;
+    cwu.sendMouseEventToWindow(aName, Math.round(aX), Math.round(aY), aButton, 1, 0, true);
+  },
+
   _findScrollableElement: function(elem, checkElem) {
     // Walk the DOM tree until we find a scrollable element
     let scrollable = false;
     while (elem) {
       /* Element is scrollable if its scroll-size exceeds its client size, and:
        * - It has overflow 'auto' or 'scroll'
        * - It's a textarea
        * - It's an HTML/BODY node
@@ -1277,16 +1320,171 @@ var BrowserEventHandler = {
     } else if (elem.scrollTop >= (elem.scrollHeight - elem.clientHeight)) {
       scrollY = false;
     }
 
     return scrollX || scrollY;
   }
 };
 
+const kReferenceDpi = 240; // standard "pixel" size used in some preferences
+
+const ElementTouchHelper = {
+  elementFromPoint: function(aWindow, aX, aY) {
+    // browser's elementFromPoint expect browser-relative client coordinates.
+    // subtract browser's scroll values to adjust
+    let cwu = aWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+    aX = aX;
+    aY = aY;
+    let elem = this.getClosest(cwu, aX, aY);
+
+    // step through layers of IFRAMEs and FRAMES to find innermost element
+    while (elem && (elem instanceof HTMLIFrameElement || elem instanceof HTMLFrameElement)) {
+      // adjust client coordinates' origin to be top left of iframe viewport
+      let rect = elem.getBoundingClientRect();
+      aX -= rect.left;
+      aY -= rect.top;
+      cwu = elem.contentDocument.defaultView.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+      elem = ElementTouchHelper.getClosest(cwu, aX, aY);
+    }
+  
+    return elem;
+  },
+
+  get radius() {
+    let prefs = Services.prefs;
+    delete this.radius;
+    return this.radius = { "top": prefs.getIntPref("browser.ui.touch.top"),
+                           "right": prefs.getIntPref("browser.ui.touch.right"),
+                           "bottom": prefs.getIntPref("browser.ui.touch.bottom"),
+                           "left": prefs.getIntPref("browser.ui.touch.left")
+                         };
+  },
+
+  get weight() {
+    delete this.weight;
+    return this.weight = { "visited": Services.prefs.getIntPref("browser.ui.touch.weight.visited") };
+  },
+
+  /* Retrieve the closest element to a point by looking at borders position */
+  getClosest: function getClosest(aWindowUtils, aX, aY) {
+    if (!this.dpiRatio)
+      this.dpiRatio = aWindowUtils.displayDPI / kReferenceDpi;
+
+    let dpiRatio = this.dpiRatio;
+
+    let target = aWindowUtils.elementFromPoint(aX, aY,
+                                               true,   /* ignore root scroll frame*/
+                                               false); /* don't flush layout */
+
+    // return null if the click is over a clickable element
+    if (this._isElementClickable(target))
+      return null;
+    let target = null;
+    let nodes = aWindowUtils.nodesFromRect(aX, aY, this.radius.top * dpiRatio,
+                                                   this.radius.right * dpiRatio,
+                                                   this.radius.bottom * dpiRatio,
+                                                   this.radius.left * dpiRatio, true, false);
+
+    let threshold = Number.POSITIVE_INFINITY;
+    for (let i = 0; i < nodes.length; i++) {
+      let current = nodes[i];
+      if (!current.mozMatchesSelector || !this._isElementClickable(current))
+        continue;
+
+      let rect = current.getBoundingClientRect();
+      let distance = this._computeDistanceFromRect(aX, aY, rect);
+
+      // increase a little bit the weight for already visited items
+      if (current && current.mozMatchesSelector("*:visited"))
+        distance *= (this.weight.visited / 100);
+
+      if (distance < threshold) {
+        target = current;
+        threshold = distance;
+      }
+    }
+
+    return target;
+  },
+
+  _isElementClickable: function _isElementClickable(aElement) {
+    const selector = "a,:link,:visited,[role=button],button,input,select,textarea,label";
+    for (let elem = aElement; elem; elem = elem.parentNode) {
+      if (this._hasMouseListener(elem))
+        return true;
+      if (elem.mozMatchesSelector && elem.mozMatchesSelector(selector))
+        return true;
+    }
+    return false;
+  },
+
+  _computeDistanceFromRect: function _computeDistanceFromRect(aX, aY, aRect) {
+    let x = 0, y = 0;
+    let xmost = aRect.left + aRect.width;
+    let ymost = aRect.top + aRect.height;
+
+    // compute horizontal distance from left/right border depending if X is
+    // before/inside/after the element's rectangle
+    if (aRect.left < aX && aX < xmost)
+      x = Math.min(xmost - aX, aX - aRect.left);
+    else if (aX < aRect.left)
+      x = aRect.left - aX;
+    else if (aX > xmost)
+      x = aX - xmost;
+
+    // compute vertical distance from top/bottom border depending if Y is
+    // above/inside/below the element's rectangle
+    if (aRect.top < aY && aY < ymost)
+      y = Math.min(ymost - aY, aY - aRect.top);
+    else if (aY < aRect.top)
+      y = aRect.top - aY;
+    if (aY > ymost)
+      y = aY - ymost;
+
+    return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
+  },
+
+  _els: Cc["@mozilla.org/eventlistenerservice;1"].getService(Ci.nsIEventListenerService),
+  _clickableEvents: ["mousedown", "mouseup", "click"],
+  _hasMouseListener: function _hasMouseListener(aElement) {
+    let els = this._els;
+    let listeners = els.getListenerInfoFor(aElement, {});
+    for (let i = 0; i < listeners.length; i++) {
+      if (this._clickableEvents.indexOf(listeners[i].type) != -1)
+        return true;
+    }
+    return false;
+  },
+  getContentClientRects: function(aElement) {
+    let offset = {x: 0, y: 0};
+  
+    let nativeRects = aElement.getClientRects();
+    // step out of iframes and frames, offsetting scroll values
+    for (let frame = aElement.ownerDocument.defaultView; frame != content; frame = frame.parent) {
+      // adjust client coordinates' origin to be top left of iframe viewport
+      let rect = frame.frameElement.getBoundingClientRect();
+      let left = frame.getComputedStyle(frame.frameElement, "").borderLeftWidth;
+      let top = frame.getComputedStyle(frame.frameElement, "").borderTopWidth;
+      offset.x += rect.left + parseInt(left);
+      offset.y += rect.top + parseInt(top);
+    }
+  
+    let result = [];
+    for (let i = nativeRects.length - 1; i >= 0; i--) {
+      let r = nativeRects[i];
+      result.push({ left: r.left + offset.x,
+                    top: r.top + offset.y,
+                    width: r.width,
+                    height: r.height
+                  });
+    }
+    return result;
+  }
+};
 
 var ErrorPageEventHandler = {
   handleEvent: function(aEvent) {
     switch (aEvent.type) {
       case "click": {
         // Don't trust synthetic events
         if (!aEvent.isTrusted)
           return;