Bug 697701 - Double tap zoom support. r=cwiiis
authorWes Johnston <wjohnston@mozilla.com>
Tue, 15 Nov 2011 13:41:19 -0800
changeset 83685 d29a6f00f79b6ff661f7aec67e38facfe5bc8ae7
parent 83684 f090c3ed08e0300f0a9d172cb589af0223a1d85a
child 83686 b061277d568819d3bdfbd55249deed5897134ff1
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)
reviewerscwiiis
bugs697701
milestone11.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 697701 - Double tap zoom support. r=cwiiis
mobile/android/base/gfx/LayerController.java
mobile/android/base/gfx/LayerView.java
mobile/android/base/gfx/PointUtils.java
mobile/android/base/gfx/RectUtils.java
mobile/android/base/ui/PanZoomController.java
mobile/android/chrome/content/browser.js
--- a/mobile/android/base/gfx/LayerController.java
+++ b/mobile/android/base/gfx/LayerController.java
@@ -61,16 +61,18 @@ import java.lang.Math;
 import java.util.ArrayList;
 
 /**
  * The layer controller manages a tile that represents the visible page. It does panning and
  * zooming natively by delegating to a panning/zooming controller. Touch events can be dispatched
  * to a higher-level view.
  */
 public class LayerController {
+    private static final String LOGTAG = "GeckoLayerController";
+
     private Layer mRootLayer;                   /* The root layer. */
     private LayerView mView;                    /* The main rendering view. */
     private Context mContext;                   /* The current context. */
     private ViewportMetrics mViewportMetrics;   /* The current viewport metrics. */
 
     private PanZoomController mPanZoomController;
     /*
      * The panning and zooming controller, which interprets pan and zoom gestures for us and
@@ -137,16 +139,17 @@ public class LayerController {
         return mViewportMetrics.getZoomFactor();
     }
 
     public Bitmap getCheckerboardPattern()  { return getDrawable("checkerboard"); }
     public Bitmap getShadowPattern()        { return getDrawable("shadow"); }
 
     public GestureDetector.OnGestureListener getGestureListener()                   { return mPanZoomController; }
     public ScaleGestureDetector.OnScaleGestureListener getScaleGestureListener()    { return mPanZoomController; }
+    public GestureDetector.OnDoubleTapListener getDoubleTapListener()               { return mPanZoomController; }
 
     private Bitmap getDrawable(String name) {
         Resources resources = mContext.getResources();
         int resourceID = resources.getIdentifier(name, "drawable", mContext.getPackageName());
         BitmapFactory.Options options = new BitmapFactory.Options();
         options.inScaled = false;
         return BitmapFactory.decodeResource(mContext.getResources(), resourceID, options);
     }
@@ -214,26 +217,35 @@ public class LayerController {
 
         // We assume this was called by the LayerClient (as it includes page
         // size), so no need to notify it of this change.
         mPanZoomController.geometryChanged(true);
         GeckoApp.mAppContext.repositionPluginViews(false);
         mView.requestRender();
     }
 
-    public void scaleTo(float zoomFactor, PointF focus) {
+    public void scaleTo(float zoomFactor) {
+        scaleWithFocus(zoomFactor, new PointF(0,0));
+    }
+
+    public void scaleWithFocus(float zoomFactor, PointF focus) {
         mViewportMetrics.scaleTo(zoomFactor, focus);
 
         // We assume the zoom level will only be modified by the
         // PanZoomController, so no need to notify it of this change.
         notifyLayerClientOfGeometryChange();
         GeckoApp.mAppContext.repositionPluginViews(false);
         mView.requestRender();
     }
 
+    public void scaleWithOrigin(float zoomFactor, PointF origin) {
+        mViewportMetrics.setOrigin(origin);
+        scaleTo(zoomFactor);
+    }
+
     public boolean post(Runnable action) { return mView.post(action); }
 
     public void setOnTouchListener(OnTouchListener onTouchListener) {
         mOnTouchListener = onTouchListener;
     }
 
     /**
      * The view as well as the controller itself use this method to notify the layer client that
@@ -257,16 +269,21 @@ public class LayerController {
         return aboutToCheckerboard() && mPanZoomController.getRedrawHint();
     }
 
     private RectF getTileRect() {
         float x = mRootLayer.getOrigin().x, y = mRootLayer.getOrigin().y;
         return new RectF(x, y, x + TILE_WIDTH, y + TILE_HEIGHT);
     }
 
+    public RectF restrictToPageSize(RectF aRect) {
+        FloatSize pageSize = getPageSize();
+        return RectUtils.restrict(aRect, new RectF(0, 0, pageSize.width, pageSize.height));
+    }
+
     // Returns true if a checkerboard is about to be visible.
     private boolean aboutToCheckerboard() {
         // Increase the size of the viewport (and clamp to page boundaries), and
         // intersect it with the tile's displayport to determine whether we're
         // close to checkerboarding.
         FloatSize pageSize = getPageSize();
         RectF adjustedViewport = RectUtils.expand(getViewport(), DANGER_ZONE_X, DANGER_ZONE_Y);
         if (adjustedViewport.top < 0) adjustedViewport.top = 0;
--- a/mobile/android/base/gfx/LayerView.java
+++ b/mobile/android/base/gfx/LayerView.java
@@ -70,16 +70,17 @@ public class LayerView extends GLSurface
 
         mContext = context;
         mController = controller;
         mRenderer = new LayerRenderer(this);
         setRenderer(mRenderer);
         setRenderMode(RENDERMODE_WHEN_DIRTY);
         mGestureDetector = new GestureDetector(context, controller.getGestureListener());
         mScaleGestureDetector = new ScaleGestureDetector(context, controller.getScaleGestureListener());
+        mGestureDetector.setOnDoubleTapListener(controller.getDoubleTapListener());
         mInputConnectionHandler = null;
 
         setFocusable(true);
         setFocusableInTouchMode(true);
     }
 
     @Override
     public boolean onTouchEvent(MotionEvent event) {
--- a/mobile/android/base/gfx/PointUtils.java
+++ b/mobile/android/base/gfx/PointUtils.java
@@ -52,10 +52,19 @@ public final class PointUtils {
 
     public static PointF scale(PointF point, float factor) {
         return new PointF(point.x * factor, point.y * factor);
     }
 
     public static Point round(PointF point) {
         return new Point(Math.round(point.x), Math.round(point.y));
     }
+
+   /* Returns a new point that is a linear interpolation between start and end points. weight conrols the weighting
+    * of each of the original points (weight = 1 returns endPoint, weight = 0 returns startPoint)
+    */
+   public static PointF interpolate(PointF startPoint, PointF endPoint, float weight) {
+       float x = (startPoint.x-endPoint.x)*weight + endPoint.x;
+       float y = (startPoint.y-endPoint.y)*weight + endPoint.y;
+       return new PointF(x, y);
+   }
 }
 
--- a/mobile/android/base/gfx/RectUtils.java
+++ b/mobile/android/base/gfx/RectUtils.java
@@ -101,9 +101,21 @@ public final class RectUtils {
     public static Rect round(RectF rect) {
         return new Rect(Math.round(rect.left), Math.round(rect.top),
                         Math.round(rect.right), Math.round(rect.bottom));
     }
 
     public static IntSize getSize(Rect rect) {
         return new IntSize(rect.width(), rect.height());
     }
+
+    /* Returns a new RectF which restricts a source rect to the area inside a second destination rect.
+     * If the source rect is wider/taller than the destination rect, it's width/height will be shortened
+     * (and its aspect ratio will NOT be maintained).
+    */
+    public static RectF restrict(RectF rect, RectF dest) {
+        float width = Math.min(rect.width(), dest.width());
+        float height = Math.min(rect.height(), dest.height());
+        float x = Math.max(dest.left, Math.min(dest.right-width, rect.left));
+        float y = Math.max(dest.top, Math.min(dest.bottom-height, rect.top));
+        return new RectF(x, y, x+width, y+height);
+    }
 }
--- a/mobile/android/base/ui/PanZoomController.java
+++ b/mobile/android/base/ui/PanZoomController.java
@@ -35,41 +35,43 @@
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.ui;
 
 import org.json.JSONObject;
 import org.mozilla.gecko.gfx.FloatSize;
 import org.mozilla.gecko.gfx.LayerController;
+import org.mozilla.gecko.gfx.PointUtils;
+import org.mozilla.gecko.gfx.RectUtils;
 import org.mozilla.gecko.FloatUtils;
 import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
-import android.graphics.Point;
+import org.mozilla.gecko.GeckoEventListener;
 import android.graphics.PointF;
-import android.graphics.Rect;
 import android.graphics.RectF;
 import android.util.Log;
 import android.view.GestureDetector;
 import android.view.MotionEvent;
 import android.view.ScaleGestureDetector;
 import java.lang.Math;
+import java.util.Date;
 import java.util.Timer;
 import java.util.TimerTask;
 
 /*
  * Handles the kinetic scrolling and zooming physics for a layer controller.
  *
  * Many ideas are from Joe Hewitt's Scrollability:
  *   https://github.com/joehewitt/scrollability/
  */
 public class PanZoomController
     extends GestureDetector.SimpleOnGestureListener
-    implements ScaleGestureDetector.OnScaleGestureListener
+    implements ScaleGestureDetector.OnScaleGestureListener, GeckoEventListener
 {
     private static final String LOGTAG = "GeckoPanZoomController";
 
     private LayerController mController;
 
     private static final float FRICTION = 0.85f;
     // Animation stops if the velocity is below this value.
     private static final float STOPPED_THRESHOLD = 4.0f;
@@ -85,16 +87,18 @@ public class PanZoomController
     // The distance the user has to pan before we recognize it as such (e.g. to avoid
     // 1-pixel pans between the touch-down and touch-up of a click). In units of inches.
     private static final float PAN_THRESHOLD = 0.1f;
     // Angle from axis within which we stay axis-locked
     private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
     // The maximum velocity change factor between events, per ms, in %.
     // Direction changes are excluded.
     private static final float MAX_EVENT_ACCELERATION = 0.012f;
+    // Length of time to spend zooming. in ms
+    public static final int ZOOM_DURATION         = 200;
 
     private Timer mFlingTimer;
     private Axis mX, mY;
     /* The zoom focus at the first zoom event (in page coordinates). */
     private PointF mLastZoomFocus;
     /* The time the last motion event took place. */
     private long mLastEventTime;
 
@@ -103,26 +107,78 @@ public class PanZoomController
         FLING,          /* all touches removed, but we're still scrolling page */
         TOUCHING,       /* one touch-start event received */
         PANNING_LOCKED, /* touch-start followed by move (i.e. panning with axis lock) */
         PANNING,        /* panning without axis lock */
         PANNING_HOLD,   /* in panning, but not moving.
                          * similar to TOUCHING but after starting a pan */
         PANNING_HOLD_LOCKED, /* like PANNING_HOLD, but axis lock still in effect */
         PINCHING,       /* nth touch-start, where n > 1. this mode allows pan and zoom */
+        ANIMATED_ZOOM   /* animated zoom to a new rect */
     }
 
     private PanZoomState mState;
 
     public PanZoomController(LayerController controller) {
         mController = controller;
         mX = new Axis(); mY = new Axis();
         mState = PanZoomState.NOTHING;
 
         populatePositionAndLength();
+
+        GeckoAppShell.registerGeckoEventListener("Browser:ZoomToRect", this);
+        GeckoAppShell.registerGeckoEventListener("Browser:ZoomToPageWidth", this);
+    }
+
+    protected void finalize() throws Throwable {
+        GeckoAppShell.unregisterGeckoEventListener("Browser:ZoomToRect", this);
+        GeckoAppShell.unregisterGeckoEventListener("Browser:ZoomToPageWidth", this);
+        super.finalize();
+    }
+    
+    public void handleMessage(String event, JSONObject message) {
+        Log.i(LOGTAG, "Got message: " + event);
+        try {
+            if (event.equals("Browser:ZoomToRect")) {
+                if (mController != null) {
+                    float scale = mController.getZoomFactor();
+                    float x = (float)message.getDouble("x");
+                    float y = (float)message.getDouble("y");
+                    final RectF zoomRect = new RectF(x, y,
+                                         x + (float)message.getDouble("w"),
+                                         y + (float)message.getDouble("h"));
+                    mController.post(new Runnable() {
+                        public void run() {
+                            animatedZoomTo(zoomRect);
+                        }
+                    });
+                }
+            } else if (event.equals("Browser:ZoomToPageWidth")) {
+                if (mController != null) {
+                    float scale = mController.getZoomFactor();
+                    FloatSize pageSize = mController.getPageSize();
+
+                    RectF viewableRect = mController.getViewport();
+                    float y = viewableRect.top;
+                    // attempt to keep zoom keep focused on the center of the viewport
+                    float dh = viewableRect.height()*(1 - pageSize.width/viewableRect.width()); // increase in the height
+                    final RectF r = new RectF(0.0f,
+                                        y + dh/2,
+                                        pageSize.width,
+                                        (y + pageSize.width * viewableRect.height()/viewableRect.width()));
+                    mController.post(new Runnable() {
+                        public void run() {
+                            animatedZoomTo(r);
+                        }
+                    });
+                }
+            }
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+        }
     }
 
     public boolean onTouchEvent(MotionEvent event) {
         switch (event.getAction() & event.ACTION_MASK) {
         case MotionEvent.ACTION_DOWN:   return onTouchStart(event);
         case MotionEvent.ACTION_MOVE:   return onTouchMove(event);
         case MotionEvent.ACTION_UP:     return onTouchEnd(event);
         case MotionEvent.ACTION_CANCEL: return onTouchCancel(event);
@@ -157,16 +213,18 @@ public class PanZoomController
         // user is taking control of movement, so stop
         // any auto-movement we have going
         if (mFlingTimer != null) {
             mFlingTimer.cancel();
             mFlingTimer = null;
         }
 
         switch (mState) {
+        case ANIMATED_ZOOM:
+            return false;
         case FLING:
         case NOTHING:
             mState = PanZoomState.TOUCHING;
             mX.velocity = mY.velocity = 0.0f;
             mX.locked = mY.locked = false;
             mX.lastTouchPos = mX.firstTouchPos = mX.touchPos = event.getX(0);
             mY.lastTouchPos = mY.firstTouchPos = mY.touchPos = event.getY(0);
             mLastEventTime = event.getEventTime();
@@ -203,16 +261,17 @@ public class PanZoomController
             track(event);
             return true;
         case PANNING_HOLD:
             mState = PanZoomState.PANNING;
             // fall through
         case PANNING:
             track(event);
             return true;
+        case ANIMATED_ZOOM:
         case PINCHING:
             // scale gesture listener will handle this
             return false;
         }
         Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchMove");
         return false;
     }
 
@@ -247,16 +306,18 @@ public class PanZoomController
                 int pointRemainingIndex = 1 - pointRemovedIndex; // kind of a hack
                 mState = PanZoomState.TOUCHING;
                 mX.firstTouchPos = mX.touchPos = event.getX(pointRemainingIndex);
                 mX.firstTouchPos = mY.touchPos = event.getY(pointRemainingIndex);
             } else {
                 // still pinching, do nothing
             }
             return true;
+        case ANIMATED_ZOOM:
+            return false;
         }
         Log.e(LOGTAG, "Unhandled case " + mState + " in onTouchEnd");
         return false;
     }
 
     private boolean onTouchCancel(MotionEvent event) {
         mState = PanZoomState.NOTHING;
         // ensure we snap back if we're overscrolled
@@ -683,49 +744,68 @@ public class PanZoomController
         }
     }
 
     /*
      * Zooming
      */
     @Override
     public boolean onScale(ScaleGestureDetector detector) {
+        if (mState == PanZoomState.ANIMATED_ZOOM)
+            return false;
+
         float newZoomFactor = mController.getZoomFactor() *
                               (detector.getCurrentSpan() / detector.getPreviousSpan());
 
         mController.scrollBy(new PointF(mLastZoomFocus.x - detector.getFocusX(),
                                         mLastZoomFocus.y - detector.getFocusY()));
-        mController.scaleTo(newZoomFactor, new PointF(detector.getFocusX(), detector.getFocusY()));
+        mController.scaleWithFocus(newZoomFactor, new PointF(detector.getFocusX(), detector.getFocusY()));
 
         mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
 
         return true;
     }
 
     @Override
     public boolean onScaleBegin(ScaleGestureDetector detector) {
+        if (mState == PanZoomState.ANIMATED_ZOOM)
+            return false;
+
         mState = PanZoomState.PINCHING;
         mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
         GeckoApp.mAppContext.hidePluginViews();
         cancelTouch();
 
         return true;
     }
 
     @Override
     public void onScaleEnd(ScaleGestureDetector detector) {
+        PointF o = mController.getOrigin();
+        if (mState == PanZoomState.ANIMATED_ZOOM)
+            return;
+
         mState = PanZoomState.PANNING_HOLD_LOCKED;
         mX.firstTouchPos = mX.touchPos = detector.getFocusX();
         mY.firstTouchPos = mY.touchPos = detector.getFocusY();
 
-        GeckoApp.mAppContext.showPluginViews();
+        RectF viewport = mController.getViewport();
+ 
+        FloatSize pageSize = mController.getPageSize();
+        RectF pageRect = new RectF(0,0, pageSize.width, pageSize.height);
 
-        // Force a viewport synchronisation
-        mController.setForceRedraw();
-        mController.notifyLayerClientOfGeometryChange();
+        if (!pageRect.contains(viewport)) {
+            // animatedZoomTo will ensure that our destRect is within the page bounds
+            animatedZoomTo(viewport);
+        } else {
+            // Force a viewport synchronisation
+            mController.setForceRedraw();
+            mController.notifyLayerClientOfGeometryChange();
+            GeckoApp.mAppContext.showPluginViews();
+        }
     }
 
     @Override
     public void onLongPress(MotionEvent motionEvent) {
         JSONObject ret = new JSONObject();
         try {
             PointF point = new PointF(motionEvent.getX(), motionEvent.getY());
             point = mController.convertViewPointToLayerPoint(point);
@@ -779,9 +859,87 @@ public class PanZoomController
         GeckoAppShell.sendEventToGecko(e);
         return true;
     }
 
     private void cancelTouch() {
         GeckoEvent e = new GeckoEvent("Gesture:CancelTouch", "");
         GeckoAppShell.sendEventToGecko(e);
     }
+
+    @Override
+    public boolean onDoubleTap(MotionEvent motionEvent) {
+        JSONObject ret = new JSONObject();
+        try {
+            PointF point = new PointF(motionEvent.getX(), motionEvent.getY());
+            point = mController.convertViewPointToLayerPoint(point);
+            ret.put("x", (int)Math.round(point.x));
+            ret.put("y", (int)Math.round(point.y));
+        } catch(Exception ex) {
+            throw new RuntimeException(ex);
+        }
+
+        GeckoEvent e = new GeckoEvent("Gesture:DoubleTap", ret.toString());
+        GeckoAppShell.sendEventToGecko(e);
+        return true;
+    }
+
+    private Timer mZoomTimer;
+    public boolean animatedZoomTo(RectF zoomToRect) {
+        GeckoApp.mAppContext.hidePluginViews();
+
+        if (mZoomTimer != null) {
+            mZoomTimer.cancel();
+        }
+
+        mState = PanZoomState.ANIMATED_ZOOM;
+        final float startZoom = mController.getZoomFactor();
+        final PointF startPoint = mController.getOrigin();
+
+        RectF viewport = mController.getViewport();
+
+        float newHeight = zoomToRect.width() * viewport.height() / viewport.width();
+        // if the requested rect would not fill the screen, shift it to be centered
+        if (zoomToRect.height() < newHeight) {
+            zoomToRect.top -= (newHeight - zoomToRect.height())/2;
+            zoomToRect.bottom = zoomToRect.top + newHeight;
+        }
+
+        zoomToRect = mController.restrictToPageSize(zoomToRect);
+        final float finalZoom = viewport.width() * startZoom / zoomToRect.width();
+        zoomToRect = RectUtils.scale(zoomToRect, finalZoom/startZoom);
+        final PointF finalPoint = new PointF(zoomToRect.left, zoomToRect.top);
+
+        mZoomTimer = new Timer();
+        final long startTime = new Date().getTime();
+
+        mZoomTimer.scheduleAtFixedRate(new TimerTask() {
+            public void run() {
+                long now = new Date().getTime();
+                final float dt = (float)(now - startTime)/ZOOM_DURATION;
+
+                if (dt < 1) {
+                    mController.post(new Runnable() {
+                        public void run() {
+                            PointF currentPoint = PointUtils.interpolate(finalPoint, startPoint, dt);
+                            float  currentScale = startZoom + (finalZoom-startZoom)*dt;
+                            mController.scaleWithOrigin(currentScale, currentPoint);
+                        }
+                    });
+                } else {
+                    mController.post(new Runnable() {
+                        public void run() {
+                            mController.scaleWithOrigin(finalZoom, finalPoint);
+                            mController.setForceRedraw();
+                            GeckoApp.mAppContext.showPluginViews();
+                            mController.notifyLayerClientOfGeometryChange();
+                            populatePositionAndLength();
+                        }
+                    });
+                    mZoomTimer.cancel();
+                    mZoomTimer = null;
+                    mState = PanZoomState.NOTHING;
+               }
+            }
+        }, 0, 1000L/60L);
+        return true;
+    }
 }
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -1410,16 +1410,17 @@ var BrowserEventHandler = {
     window.addEventListener("click", this, true);
     window.addEventListener("mousedown", this, true);
     window.addEventListener("mouseup", this, true);
     window.addEventListener("mousemove", this, true);
 
     Services.obs.addObserver(this, "Gesture:SingleTap", false);
     Services.obs.addObserver(this, "Gesture:ShowPress", false);
     Services.obs.addObserver(this, "Gesture:CancelTouch", false);
+    Services.obs.addObserver(this, "Gesture:DoubleTap", false);
 
     BrowserApp.deck.addEventListener("DOMContentLoaded", this, true);
     BrowserApp.deck.addEventListener("DOMLinkAdded", this, true);
     BrowserApp.deck.addEventListener("DOMTitleChanged", this, true);
     BrowserApp.deck.addEventListener("DOMUpdatePageReport", PopupBlockerObserver.onUpdatePageReport, false);
   },
 
   observe: function(aSubject, aTopic, aData) {
@@ -1440,16 +1441,65 @@ var BrowserEventHandler = {
         let data = JSON.parse(aData);
         [data.x, data.y] = ElementTouchHelper.toScreenCoords(element.ownerDocument.defaultView, data.x, data.y);
 
         this._sendMouseEvent("mousemove", element, data.x, data.y);
         this._sendMouseEvent("mousedown", element, data.x, data.y);
         this._sendMouseEvent("mouseup",   element, data.x, data.y);
       }
       this._cancelTapHighlight();
+    } else if (aTopic == "Gesture:DoubleTap") {
+      this._cancelTapHighlight();
+      this.onDoubleTap(aData);
+    }
+  },
+ 
+  _zoomOut: function() {
+    this._zoomedToElement = null;
+    // zoom out, try to keep the center in the center of the page
+    setTimeout(function() {
+      sendMessageToJava({ gecko: { type: "Browser:ZoomToPageWidth"} });
+    }, 0);    
+  },
+
+  onDoubleTap: function(aData) {
+    let data = JSON.parse(aData);
+
+    let rect = {};
+    let win = BrowserApp.selectedBrowser.contentWindow;
+    
+    let zoom = BrowserApp.selectedTab._viewport.zoom;
+    let element = ElementTouchHelper.anyElementFromPoint(win, data.x, data.y);
+    if (!element) {
+      this._zoomOut();
+      return;
+    }
+
+    win = element.ownerDocument.defaultView;
+    while (element && win.getComputedStyle(element,null).display == "inline")
+      element = element.parentNode;
+    if (!element || element == this._zoomedToElement) {
+      this._zoomOut();
+    } else if (element) {
+      const margin = 15;
+      this._zoomedToElement = element;
+      rect = ElementTouchHelper.getBoundingContentRect(element);
+
+      let zoom = BrowserApp.selectedTab.viewport.zoom;
+      rect.x *= zoom;
+      rect.y *= zoom;
+      rect.w *= zoom;
+      rect.h *= zoom;
+
+      setTimeout(function() {
+        rect.type = "Browser:ZoomToRect";
+        rect.x -= margin;
+        rect.w += 2*margin;
+        sendMessageToJava({ gecko: rect });
+      }, 0);
     }
   },
 
   _highlihtElement: null,
 
   _doTapHighlight: function _doTapHighlight(aElement) {
     DOMUtils.setContentState(aElement, kStateActive);
     this._highlightElement = aElement;
@@ -1868,16 +1918,49 @@ const ElementTouchHelper = {
       let r = nativeRects[i];
       result.push({ left: r.left + offset.x,
                     top: r.top + offset.y,
                     width: r.width,
                     height: r.height
                   });
     }
     return result;
+  },
+  getBoundingContentRect: function(aElement) {
+    if (!aElement)
+      return {x: 0, y: 0, w: 0, h: 0};
+  
+    let document = aElement.ownerDocument;
+    while (document.defaultView.frameElement)
+      document = document.defaultView.frameElement.ownerDocument;
+  
+    let cwu = document.defaultView.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
+    let scrollX = {}, scrollY = {};
+    cwu.getScrollXY(false, scrollX, scrollY);
+  
+    let r = aElement.getBoundingClientRect();
+ 
+    // step out of iframes and frames, offsetting scroll values
+    for (let frame = aElement.ownerDocument.defaultView; frame.frameElement && 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;
+      scrollX.value += rect.left + parseInt(left);
+      scrollY.value += rect.top + parseInt(top);
+    }
+
+    var x = r.left + scrollX.value;
+    var y = r.top + scrollY.value;
+    var x2 = x + r.width;
+    var y2 = y + r.height;
+    return {x: x,
+            y: y,
+            w: x2 - x,
+            h: y2 - y};
   }
 };
 
 var ErrorPageEventHandler = {
   handleEvent: function(aEvent) {
     switch (aEvent.type) {
       case "click": {
         // Don't trust synthetic events