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 81682 8447eaf8f0f86e2277c9a9e47c1bd958b10e5135
parent 81681 a9fb813f816effdf27596a7ebb091d4375166a6b
child 81683 5860a54b71993f080493c8691cf07cb40a24e571
push idunknown
push userunknown
push dateunknown
reviewersmbrubeck
bugs696512
milestone10.0a1
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;