per-message zoom using JavaScript + CSS 3D transforms

When Auto-fit is off, disable built-in WebView zooming, which
is only capable of zooming an entire document (the entire
conversation).

Use ScaleGestureDetector to trigger a CSS 3D transform to zoom
in/out just a single message div.

During the gesture, the elements above and below the message
being scaled are untouched. This is ugly.
TODO: they should either move away (tricky) or at least fade
out.

When the gesture is complete, to avoid leaving overlapping
elements, we force-scale the height and reset the transform
origin to the top left.
TODO: auto-scroll to the correct place to counteract this
perceived jump.

Double-tap is not yet implemented. We'll have to do it
ourselves.

Bug: 7478834
Change-Id: I114e4977304c7060d499d116cc75bc0488967448
diff --git a/assets/script.js b/assets/script.js
index 4a01578..6ea24e8 100644
--- a/assets/script.js
+++ b/assets/script.js
@@ -21,6 +21,8 @@
 // this is an Array, but we treat it like a Set and only insert unique items
 var gImageLoadElements = [];
 
+var gScaleInfo;
+
 /**
  * Returns the page offset of an element.
  *
@@ -59,6 +61,17 @@
     return parent || null;
 }
 
+function getCachedValue(div, property, attrName) {
+    var value;
+    if (div.hasAttribute(attrName)) {
+        value = div.getAttribute(attrName);
+    } else {
+        value = div[property];
+        div.setAttribute(attrName, value);
+    }
+    return value;
+}
+
 function onToggleClick(e) {
     toggleQuotedText(e.target);
     measurePositions();
@@ -448,6 +461,113 @@
     measurePositions();
 }
 
+function onScaleBegin(screenX, screenY) {
+//    console.log("JS got scaleBegin x/y=" + screenX + "/" + screenY);
+    var focusX = screenX + document.body.scrollLeft;
+    var focusY = screenY + document.body.scrollTop;
+    var i, len;
+    var msgDivs = document.getElementsByClassName("mail-message");
+    var msgDiv, msgBodyDiv;
+    var msgTop, msgDivTop, nextMsgTop;
+    var initialH;
+    var initialScale;
+    var scaledOriginX, scaledOriginY;
+    var translateX, translateY;
+    var origin;
+
+    gScaleInfo = undefined;
+
+    for (i = 0, len = msgDivs.length; i < len; i++) {
+        msgDiv = msgDivs[i];
+        msgTop = nextMsgTop ? nextMsgTop : getTotalOffset(msgDiv).top;
+        nextMsgTop = (i < len-1) ? getTotalOffset(msgDivs[i+1]).top : document.body.offsetHeight;
+        if (focusY >= msgTop && focusY < nextMsgTop) {
+            msgBodyDiv = msgDiv.children[1];
+            initialScale = msgBodyDiv.getAttribute("data-initial-scale") || 1.0;
+
+            msgDivTop = getTotalOffset(msgBodyDiv).top;
+
+            scaledOriginX = focusX;// / initialScale;
+            scaledOriginY = (focusY - msgDivTop);// / initialScale;
+
+            translateX = scaledOriginX * (initialScale - 1.0) / initialScale;
+            translateY = scaledOriginY * (initialScale - 1.0) / initialScale;
+
+            gScaleInfo = {
+                div: msgBodyDiv,
+                divTop: msgDivTop,
+                initialScale: initialScale,
+                initialX: focusX,
+                initialY: focusY,
+                translateX: translateX,
+                translateY: translateY,
+                initialH: getCachedValue(msgBodyDiv, "offsetHeight", "data-initial-height"),
+                minScale: Math.min(document.body.offsetWidth / msgBodyDiv.scrollWidth, 1.0),
+                currScale: initialScale
+            };
+
+            origin = scaledOriginX + "px " + scaledOriginY + "px";
+            msgBodyDiv.classList.add("zooming-focused");
+            msgBodyDiv.style.webkitTransformOrigin = origin;
+            msgBodyDiv.style.webkitTransform = "scale3d(" + initialScale + "," + initialScale
+                + ",1) translate3d(" + translateX + "px," + translateY + "px,0)";
+//            console.log("scaleBegin, h=" + gScaleInfo.initialH + " origin='" + origin + "'");
+            break;
+        }
+    }
+}
+
+function onScaleEnd(screenX, screenY) {
+    var msgBodyDiv;
+    var scale;
+    var h;
+    if (!gScaleInfo) {
+        return;
+    }
+
+//    console.log("JS got scaleEnd x/y=" + screenX + "/" + screenY);
+    msgBodyDiv = gScaleInfo.div;
+    scale = gScaleInfo.currScale;
+    msgBodyDiv.style.webkitTransformOrigin = "0 0";
+    // clear any translate
+    msgBodyDiv.style.webkitTransform = "scale3d(" + scale + "," + scale + ",1)";
+    // switching to a 2D transform here re-renders the fonts more clearly, but introduces
+    // texture upload lag to any subsequent scale operation
+    //msgBodyDiv.style.webkitTransform = "scale(" + gScaleInfo.currScale + ")";
+    h = gScaleInfo.initialH * scale;
+//    console.log("onScaleEnd set h=" + h);
+    msgBodyDiv.style.height = h + "px";
+    msgBodyDiv.classList.remove("zooming-focused");
+    msgBodyDiv.setAttribute("data-initial-scale", scale);
+}
+
+function onScale(relativeScale, screenX, screenY) {
+    var focusX, focusY;
+    var scale;
+    var translateX, translateY;
+    var transform;
+
+    if (!gScaleInfo) {
+        return;
+    }
+    focusX = screenX + document.body.scrollLeft;
+    focusY = screenY + document.body.scrollTop;
+
+    scale = Math.max(gScaleInfo.initialScale * relativeScale, gScaleInfo.minScale);
+    if (scale > 4.0) {
+        scale = 4.0;
+    }
+    gScaleInfo.currScale = scale;
+    translateX = focusX - gScaleInfo.initialX;
+    translateY = focusY - gScaleInfo.initialY;
+    transform = "translate3d(" + translateX + "px," + translateY + "px,0) scale3d("
+        + scale + "," + scale + ",1) translate3d(" + gScaleInfo.translateX + "px,"
+        + gScaleInfo.translateY + "px,0)";
+    gScaleInfo.div.style.webkitTransform = transform;
+//    console.log("JS got scale=" + relativeScale + " x/y=" + screenX + "/" + screenY
+//        + " transform='" + transform + "'");
+}
+
 // END Java->JavaScript handlers
 
 // Do this first to ensure that the readiness signal comes through,
diff --git a/res/raw/template_message.html b/res/raw/template_message.html
index 9de1c64..8195f7d 100644
--- a/res/raw/template_message.html
+++ b/res/raw/template_message.html
@@ -1,5 +1,5 @@
 <div id="%s" class="mail-message %s">
     <div class="mail-message-header spacer" style="height: %spx;"></div>
-    <div class="mail-message-content collapsible zoom-normal %s" style="display: %s; padding: 16px 0;">%s</div>
+    <div class="mail-message-content collapsible zoom-normal %s" style="display: %s; margin: 16px 0;">%s</div>
     <div class="mail-message-footer spacer collapsible" style="display: %s; height: %spx;"></div>
 </div>
diff --git a/src/com/android/mail/browse/ConversationWebView.java b/src/com/android/mail/browse/ConversationWebView.java
index 857db7a..f17ca3c 100644
--- a/src/com/android/mail/browse/ConversationWebView.java
+++ b/src/com/android/mail/browse/ConversationWebView.java
@@ -23,6 +23,8 @@
 import android.graphics.Canvas;
 import android.util.AttributeSet;
 import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.ScaleGestureDetector.OnScaleGestureListener;
 import android.webkit.WebView;
 
 import com.android.mail.R;
@@ -145,6 +147,8 @@
 
     private ContentSizeChangeListener mSizeChangeListener;
 
+    private ScaleGestureDetector mScaleDetector;
+
     private int mCachedContentHeight;
 
     private final int mViewportWidth;
@@ -188,6 +192,13 @@
         mSizeChangeListener = l;
     }
 
+    public void setOnScaleGestureListener(OnScaleGestureListener l) {
+        if (l == null) {
+            mScaleDetector = null;
+        } else {
+            mScaleDetector = new ScaleGestureDetector(getContext(), l);
+        }
+    }
 
     @Override
     public int computeVerticalScrollRange() {
@@ -259,7 +270,13 @@
                 break;
         }
 
-        return super.onTouchEvent(ev);
+        final boolean handled = super.onTouchEvent(ev);
+
+        if (mScaleDetector != null) {
+            mScaleDetector.onTouchEvent(ev);
+        }
+
+        return handled;
     }
 
     public boolean isHandlingTouch() {
@@ -297,4 +314,5 @@
     public float webPxToScreenPxError(int webPx) {
         return webPx * getInitialScale() - webPxToScreenPx(webPx);
     }
+
 }
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 28dd44f..c1ebd4a 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -30,7 +30,9 @@
 import android.os.SystemClock;
 import android.text.TextUtils;
 import android.view.LayoutInflater;
+import android.view.ScaleGestureDetector;
 import android.view.View;
+import android.view.ScaleGestureDetector.OnScaleGestureListener;
 import android.view.View.OnLayoutChangeListener;
 import android.view.ViewGroup;
 import android.webkit.ConsoleMessage;
@@ -126,6 +128,8 @@
 
     private final WebViewClient mWebViewClient = new ConversationWebViewClient();
 
+    private final ScaleInterceptor mScaleInterceptor = new ScaleInterceptor();
+
     private ConversationViewAdapter mAdapter;
 
     private boolean mViewsCreated;
@@ -143,6 +147,8 @@
 
     private int  mMaxAutoLoadMessages;
 
+    private int mSideMarginPx;
+
     /**
      * If this conversation fragment is not visible, and it's inappropriate to load up front,
      * this is the reason we are waiting. This flag should be cleared once it's okay to load
@@ -264,6 +270,10 @@
 
         mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
 
+        mSideMarginPx = getResources().getDimensionPixelOffset(
+                R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset(
+                R.dimen.conversation_message_content_margin_side);
+
         mWebView.setOnCreateContextMenuListener(new WebViewContextMenu(getActivity()));
 
         // set this up here instead of onCreateView to ensure the latest Account is loaded
@@ -596,11 +606,7 @@
         final int convHeaderPos = mAdapter.addConversationHeader(mConversation);
         final int convHeaderPx = measureOverlayHeight(convHeaderPos);
 
-        final int sideMarginPx = getResources().getDimensionPixelOffset(
-                R.dimen.conversation_view_margin_side) + getResources().getDimensionPixelOffset(
-                R.dimen.conversation_message_content_margin_side);
-
-        mTemplates.startConversation(mWebView.screenPxToWebPx(sideMarginPx),
+        mTemplates.startConversation(mWebView.screenPxToWebPx(mSideMarginPx),
                 mWebView.screenPxToWebPx(convHeaderPx));
 
         int collapsedStart = -1;
@@ -926,14 +932,17 @@
     }
 
     private void setupOverviewMode() {
+        // for now, overview mode means use the built-in WebView zoom and disable custom scale
+        // gesture handling
         final boolean overviewMode = isOverviewMode(mAccount);
         final WebSettings settings = mWebView.getSettings();
         settings.setUseWideViewPort(overviewMode);
         settings.setSupportZoom(overviewMode);
+        settings.setBuiltInZoomControls(overviewMode);
         if (overviewMode) {
-            settings.setBuiltInZoomControls(true);
             settings.setDisplayZoomControls(false);
         }
+        mWebView.setOnScaleGestureListener(overviewMode ? null : mScaleInterceptor);
     }
 
     private class ConversationWebViewClient extends AbstractConversationWebViewClient {
@@ -1348,4 +1357,38 @@
             int heightBefore) {
         mDiff = (expanded ? 1 : -1) * Math.abs(i.getHeight() - heightBefore);
     }
+
+    private class ScaleInterceptor implements OnScaleGestureListener {
+
+        private float getFocusXWebPx(ScaleGestureDetector detector) {
+            return (detector.getFocusX() - mSideMarginPx) / mWebView.getInitialScale();
+        }
+
+        private float getFocusYWebPx(ScaleGestureDetector detector) {
+            return detector.getFocusY() / mWebView.getInitialScale();
+        }
+
+        @Override
+        public boolean onScale(ScaleGestureDetector detector) {
+            mWebView.loadUrl(String.format("javascript:onScale(%s, %s, %s);",
+                    detector.getScaleFactor(), getFocusXWebPx(detector),
+                    getFocusYWebPx(detector)));
+            return false;
+        }
+
+        @Override
+        public boolean onScaleBegin(ScaleGestureDetector detector) {
+            mWebView.loadUrl(String.format("javascript:onScaleBegin(%s, %s);",
+                    getFocusXWebPx(detector), getFocusYWebPx(detector)));
+            return true;
+        }
+
+        @Override
+        public void onScaleEnd(ScaleGestureDetector detector) {
+            mWebView.loadUrl(String.format("javascript:onScaleEnd(%s, %s);",
+                    getFocusXWebPx(detector), getFocusYWebPx(detector)));
+        }
+
+    }
+
 }