Home | History | Annotate | Download | only in assets
      1 /*
      2  * Copyright (C) 2012 Google Inc.
      3  * Licensed to The Android Open Source Project.
      4  *
      5  * Licensed under the Apache License, Version 2.0 (the "License");
      6  * you may not use this file except in compliance with the License.
      7  * You may obtain a copy of the License at
      8  *
      9  *      http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  * Unless required by applicable law or agreed to in writing, software
     12  * distributed under the License is distributed on an "AS IS" BASIS,
     13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  * See the License for the specific language governing permissions and
     15  * limitations under the License.
     16  */
     17 
     18 var BLOCKED_SRC_ATTR = "blocked-src";
     19 
     20 // the set of Elements currently scheduled for processing in handleAllImageLoads
     21 // this is an Array, but we treat it like a Set and only insert unique items
     22 var gImageLoadElements = [];
     23 
     24 var gScaleInfo;
     25 
     26 /**
     27  * Only revert transforms that do an imperfect job of shrinking content if they fail
     28  * to shrink by this much. Expressed as a ratio of:
     29  * (original width difference : width difference after transforms);
     30  */
     31 var TRANSFORM_MINIMUM_EFFECTIVE_RATIO = 0.7;
     32 
     33 // Don't ship with this on.
     34 var DEBUG_DISPLAY_TRANSFORMS = false;
     35 
     36 var gTransformText = {};
     37 
     38 /**
     39  * Returns the page offset of an element.
     40  *
     41  * @param {Element} element The element to return the page offset for.
     42  * @return {left: number, top: number} A tuple including a left and top value representing
     43  *     the page offset of the element.
     44  */
     45 function getTotalOffset(el) {
     46     var result = {
     47         left: 0,
     48         top: 0
     49     };
     50     var parent = el;
     51 
     52     while (parent) {
     53         result.left += parent.offsetLeft;
     54         result.top += parent.offsetTop;
     55         parent = parent.offsetParent;
     56     }
     57 
     58     return result;
     59 }
     60 
     61 /**
     62  * Walks up the DOM starting at a given element, and returns an element that has the
     63  * specified class name or null.
     64  */
     65 function up(el, className) {
     66     var parent = el;
     67     while (parent) {
     68         if (parent.classList && parent.classList.contains(className)) {
     69             break;
     70         }
     71         parent = parent.parentNode;
     72     }
     73     return parent || null;
     74 }
     75 
     76 function getCachedValue(div, property, attrName) {
     77     var value;
     78     if (div.hasAttribute(attrName)) {
     79         value = div.getAttribute(attrName);
     80     } else {
     81         value = div[property];
     82         div.setAttribute(attrName, value);
     83     }
     84     return value;
     85 }
     86 
     87 function onToggleClick(e) {
     88     toggleQuotedText(e.target);
     89     measurePositions();
     90 }
     91 
     92 function toggleQuotedText(toggleElement) {
     93     var elidedTextElement = toggleElement.nextSibling;
     94     var isHidden = getComputedStyle(elidedTextElement).display == 'none';
     95     toggleElement.innerHTML = isHidden ? MSG_HIDE_ELIDED : MSG_SHOW_ELIDED;
     96     elidedTextElement.style.display = isHidden ? 'block' : 'none';
     97 
     98     // Revealing the elided text should normalize it to fit-width to prevent
     99     // this message from blowing out the conversation width.
    100     if (isHidden) {
    101         normalizeElementWidths([elidedTextElement]);
    102     }
    103 }
    104 
    105 function collapseAllQuotedText() {
    106     processQuotedText(document.documentElement, false /* showElided */);
    107 }
    108 
    109 function processQuotedText(elt, showElided) {
    110     var i;
    111     var elements = elt.getElementsByClassName("elided-text");
    112     var elidedElement, toggleElement;
    113     for (i = 0; i < elements.length; i++) {
    114         elidedElement = elements[i];
    115         toggleElement = document.createElement("div");
    116         toggleElement.className = "mail-elided-text";
    117         toggleElement.innerHTML = MSG_SHOW_ELIDED;
    118         toggleElement.onclick = onToggleClick;
    119         elidedElement.style.display = 'none';
    120         elidedElement.parentNode.insertBefore(toggleElement, elidedElement);
    121         if (showElided) {
    122             toggleQuotedText(toggleElement);
    123         }
    124     }
    125 }
    126 
    127 function isConversationEmpty(bodyDivs) {
    128     var i, len;
    129     var msgBody;
    130     var text;
    131 
    132     // Check if given divs are empty (in appearance), and disable zoom if so.
    133     for (i = 0, len = bodyDivs.length; i < len; i++) {
    134         msgBody = bodyDivs[i];
    135         // use 'textContent' to exclude markup when determining whether bodies are empty
    136         // (fall back to more expensive 'innerText' if 'textContent' isn't implemented)
    137         text = msgBody.textContent || msgBody.innerText;
    138         if (text.trim().length > 0) {
    139             return false;
    140         }
    141     }
    142     return true;
    143 }
    144 
    145 function normalizeAllMessageWidths() {
    146     var expandedBodyDivs;
    147     var metaViewport;
    148     var contentValues;
    149     var isEmpty;
    150 
    151     expandedBodyDivs = document.querySelectorAll(".expanded > .mail-message-content");
    152 
    153     isEmpty = isConversationEmpty(expandedBodyDivs);
    154 
    155     normalizeElementWidths(expandedBodyDivs);
    156 
    157     // assemble a working <meta> viewport "content" value from the base value in the
    158     // document, plus any dynamically determined options
    159     metaViewport = document.getElementById("meta-viewport");
    160     contentValues = [metaViewport.getAttribute("content")];
    161     if (isEmpty) {
    162         contentValues.push(metaViewport.getAttribute("data-zoom-off"));
    163     } else {
    164         contentValues.push(metaViewport.getAttribute("data-zoom-on"));
    165     }
    166     metaViewport.setAttribute("content", contentValues.join(","));
    167 }
    168 
    169 /*
    170  * Normalizes the width of all elements supplied to the document body's overall width.
    171  * Narrower elements are zoomed in, and wider elements are zoomed out.
    172  * This method is idempotent.
    173  */
    174 function normalizeElementWidths(elements) {
    175     var i;
    176     var el;
    177     var documentWidth;
    178     var newZoom, oldZoom;
    179 
    180     documentWidth = document.body.offsetWidth;
    181 
    182     for (i = 0; i < elements.length; i++) {
    183         el = elements[i];
    184         oldZoom = el.style.zoom;
    185         // reset any existing normalization
    186         if (oldZoom) {
    187             el.style.zoom = 1;
    188         }
    189         newZoom = documentWidth / el.scrollWidth;
    190         transformContent(el, documentWidth, el.scrollWidth);
    191         newZoom = documentWidth / el.scrollWidth;
    192         if (NORMALIZE_MESSAGE_WIDTHS) {
    193             el.style.zoom = newZoom;
    194         }
    195     }
    196 }
    197 
    198 function transformContent(el, docWidth, elWidth) {
    199     var nodes;
    200     var i, len;
    201     var index;
    202     var newWidth = elWidth;
    203     var wStr;
    204     var touched;
    205     // the format of entries in this array is:
    206     // entry := [ undoFunction, undoFunctionThis, undoFunctionParamArray ]
    207     var actionLog = [];
    208     var node;
    209     var done = false;
    210     var msgId;
    211     var transformText;
    212     var existingText;
    213     var textElement;
    214     var start;
    215     var beforeWidth;
    216     var tmpActionLog = [];
    217     if (elWidth <= docWidth) {
    218         return;
    219     }
    220 
    221     start = Date.now();
    222 
    223     if (el.parentElement.classList.contains("mail-message")) {
    224         msgId = el.parentElement.id;
    225         transformText = "[origW=" + elWidth + "/" + docWidth;
    226     }
    227 
    228     // Try munging all divs or textareas with inline styles where the width
    229     // is wider than docWidth, and change it to be a max-width.
    230     touched = false;
    231     nodes = ENABLE_MUNGE_TABLES ? el.querySelectorAll("div[style], textarea[style]") : [];
    232     touched = transformBlockElements(nodes, docWidth, actionLog);
    233     if (touched) {
    234         newWidth = el.scrollWidth;
    235         console.log("ran div-width munger on el=" + el + " oldW=" + elWidth + " newW=" + newWidth
    236             + " docW=" + docWidth);
    237         if (msgId) {
    238             transformText += " DIV:newW=" + newWidth;
    239         }
    240         if (newWidth <= docWidth) {
    241             done = true;
    242         }
    243     }
    244 
    245     if (!done) {
    246         // OK, that wasn't enough. Find images with widths and override their widths.
    247         nodes = ENABLE_MUNGE_IMAGES ? el.querySelectorAll("img") : [];
    248         touched = transformImages(nodes, docWidth, actionLog);
    249         if (touched) {
    250             newWidth = el.scrollWidth;
    251             console.log("ran img munger on el=" + el + " oldW=" + elWidth + " newW=" + newWidth
    252                 + " docW=" + docWidth);
    253             if (msgId) {
    254                 transformText += " IMG:newW=" + newWidth;
    255             }
    256             if (newWidth <= docWidth) {
    257                 done = true;
    258             }
    259         }
    260     }
    261 
    262     if (!done) {
    263         // OK, that wasn't enough. Find tables with widths and override their widths.
    264         // Also ensure that any use of 'table-layout: fixed' is negated, since using
    265         // that with 'width: auto' causes erratic table width.
    266         nodes = ENABLE_MUNGE_TABLES ? el.querySelectorAll("table") : [];
    267         touched = addClassToElements(nodes, shouldMungeTable, "munged",
    268             actionLog);
    269         if (touched) {
    270             newWidth = el.scrollWidth;
    271             console.log("ran table munger on el=" + el + " oldW=" + elWidth + " newW=" + newWidth
    272                 + " docW=" + docWidth);
    273             if (msgId) {
    274                 transformText += " TABLE:newW=" + newWidth;
    275             }
    276             if (newWidth <= docWidth) {
    277                 done = true;
    278             }
    279         }
    280     }
    281 
    282     if (!done) {
    283         // OK, that wasn't enough. Try munging all <td> to override any width and nowrap set.
    284         beforeWidth = newWidth;
    285         nodes = ENABLE_MUNGE_TABLES ? el.querySelectorAll("td") : [];
    286         touched = addClassToElements(nodes, null /* mungeAll */, "munged",
    287             tmpActionLog);
    288         if (touched) {
    289             newWidth = el.scrollWidth;
    290             console.log("ran td munger on el=" + el + " oldW=" + elWidth + " newW=" + newWidth
    291                 + " docW=" + docWidth);
    292             if (msgId) {
    293                 transformText += " TD:newW=" + newWidth;
    294             }
    295             if (newWidth <= docWidth) {
    296                 done = true;
    297             } else if (newWidth == beforeWidth) {
    298                 // this transform did not improve things, and it is somewhat risky.
    299                 // back it out, since it's the last transform and we gained nothing.
    300                 undoActions(tmpActionLog);
    301             } else {
    302                 // the transform WAS effective (although not 100%)
    303                 // copy the temporary action log entries over as normal
    304                 for (i = 0, len = tmpActionLog.length; i < len; i++) {
    305                     actionLog.push(tmpActionLog[i]);
    306                 }
    307             }
    308         }
    309     }
    310 
    311     // If the transformations shrank the width significantly enough, leave them in place.
    312     // We figure that in those cases, the benefits outweight the risk of rendering artifacts.
    313     if (!done && (elWidth - newWidth) / (elWidth - docWidth) >
    314             TRANSFORM_MINIMUM_EFFECTIVE_RATIO) {
    315         console.log("transform(s) deemed effective enough");
    316         done = true;
    317     }
    318 
    319     if (done) {
    320         if (msgId) {
    321             transformText += "]";
    322             existingText = gTransformText[msgId];
    323             if (!existingText) {
    324                 transformText = "Message transforms: " + transformText;
    325             } else {
    326                 transformText = existingText + " " + transformText;
    327             }
    328             gTransformText[msgId] = transformText;
    329             window.mail.onMessageTransform(msgId, transformText);
    330             if (DEBUG_DISPLAY_TRANSFORMS) {
    331                 textElement = el.firstChild;
    332                 if (!textElement.classList || !textElement.classList.contains("transform-text")) {
    333                     textElement = document.createElement("div");
    334                     textElement.classList.add("transform-text");
    335                     textElement.style.fontSize = "10px";
    336                     textElement.style.color = "#ccc";
    337                     el.insertBefore(textElement, el.firstChild);
    338                 }
    339                 textElement.innerHTML = transformText + "<br>";
    340             }
    341         }
    342         console.log("munger(s) succeeded, elapsed time=" + (Date.now() - start));
    343         return;
    344     }
    345 
    346     // reverse all changes if the width is STILL not narrow enough
    347     // (except the width->maxWidth change, which is not particularly destructive)
    348     undoActions(actionLog);
    349     if (actionLog.length > 0) {
    350         console.log("all mungers failed, changes reversed. elapsed time=" + (Date.now() - start));
    351     }
    352 }
    353 
    354 function undoActions(actionLog) {
    355     for (i = 0, len = actionLog.length; i < len; i++) {
    356         actionLog[i][0].apply(actionLog[i][1], actionLog[i][2]);
    357     }
    358 }
    359 
    360 function addClassToElements(nodes, conditionFn, classToAdd, actionLog) {
    361     var i, len;
    362     var node;
    363     var added = false;
    364     for (i = 0, len = nodes.length; i < len; i++) {
    365         node = nodes[i];
    366         if (!conditionFn || conditionFn(node)) {
    367             if (node.classList.contains(classToAdd)) {
    368                 continue;
    369             }
    370             node.classList.add(classToAdd);
    371             added = true;
    372             actionLog.push([node.classList.remove, node.classList, [classToAdd]]);
    373         }
    374     }
    375     return added;
    376 }
    377 
    378 function transformBlockElements(nodes, docWidth, actionLog) {
    379     var i, len;
    380     var node;
    381     var wStr;
    382     var index;
    383     var touched = false;
    384 
    385     for (i = 0, len = nodes.length; i < len; i++) {
    386         node = nodes[i];
    387         wStr = node.style.width || node.style.minWidth;
    388         index = wStr ? wStr.indexOf("px") : -1;
    389         if (index >= 0 && wStr.slice(0, index) > docWidth) {
    390             saveStyleProperty(node, "width", actionLog);
    391             saveStyleProperty(node, "minWidth", actionLog);
    392             saveStyleProperty(node, "maxWidth", actionLog);
    393             node.style.width = "100%";
    394             node.style.minWidth = "";
    395             node.style.maxWidth = wStr;
    396             touched = true;
    397         }
    398     }
    399     return touched;
    400 }
    401 
    402 function transformImages(nodes, docWidth, actionLog) {
    403     var i, len;
    404     var node;
    405     var w, h;
    406     var touched = false;
    407 
    408     for (i = 0, len = nodes.length; i < len; i++) {
    409         node = nodes[i];
    410         w = node.offsetWidth;
    411         h = node.offsetHeight;
    412         // shrink w/h proportionally if the img is wider than available width
    413         if (w > docWidth) {
    414             saveStyleProperty(node, "maxWidth", actionLog);
    415             saveStyleProperty(node, "width", actionLog);
    416             saveStyleProperty(node, "height", actionLog);
    417             node.style.maxWidth = docWidth + "px";
    418             node.style.width = "100%";
    419             node.style.height = "auto";
    420             touched = true;
    421         }
    422     }
    423     return touched;
    424 }
    425 
    426 function saveStyleProperty(node, property, actionLog) {
    427     var savedName = "data-" + property;
    428     node.setAttribute(savedName, node.style[property]);
    429     actionLog.push([undoSetProperty, node, [property, savedName]]);
    430 }
    431 
    432 function undoSetProperty(property, savedProperty) {
    433     this.style[property] = savedProperty ? this.getAttribute(savedProperty) : "";
    434 }
    435 
    436 function shouldMungeTable(table) {
    437     return table.hasAttribute("width") || table.style.width;
    438 }
    439 
    440 function hideAllUnsafeImages() {
    441     hideUnsafeImages(document.getElementsByClassName("mail-message-content"));
    442 }
    443 
    444 function hideUnsafeImages(msgContentDivs) {
    445     var i, msgContentCount;
    446     var j, imgCount;
    447     var msgContentDiv, image;
    448     var images;
    449     var showImages;
    450     for (i = 0, msgContentCount = msgContentDivs.length; i < msgContentCount; i++) {
    451         msgContentDiv = msgContentDivs[i];
    452         showImages = msgContentDiv.classList.contains("mail-show-images");
    453 
    454         images = msgContentDiv.getElementsByTagName("img");
    455         for (j = 0, imgCount = images.length; j < imgCount; j++) {
    456             image = images[j];
    457             rewriteRelativeImageSrc(image);
    458             attachImageLoadListener(image);
    459             // TODO: handle inline image attachments for all supported protocols
    460             if (!showImages) {
    461                 blockImage(image);
    462             }
    463         }
    464     }
    465 }
    466 
    467 /**
    468  * Changes relative paths to absolute path by pre-pending the account uri
    469  * @param {Element} imgElement Image for which the src path will be updated.
    470  */
    471 function rewriteRelativeImageSrc(imgElement) {
    472     var src = imgElement.src;
    473 
    474     // DOC_BASE_URI will always be a unique x-thread:// uri for this particular conversation
    475     if (src.indexOf(DOC_BASE_URI) == 0 && (DOC_BASE_URI != CONVERSATION_BASE_URI)) {
    476         // The conversation specifies a different base uri than the document
    477         src = CONVERSATION_BASE_URI + src.substring(DOC_BASE_URI.length);
    478         imgElement.src = src;
    479     }
    480 };
    481 
    482 
    483 function attachImageLoadListener(imageElement) {
    484     // Reset the src attribute to the empty string because onload will only fire if the src
    485     // attribute is set after the onload listener.
    486     var originalSrc = imageElement.src;
    487     imageElement.src = '';
    488     imageElement.onload = imageOnLoad;
    489     imageElement.src = originalSrc;
    490 }
    491 
    492 /**
    493  * Handle an onload event for an <img> tag.
    494  * The image could be within an elided-text block, or at the top level of a message.
    495  * When a new image loads, its new bounds may affect message or elided-text geometry,
    496  * so we need to inspect and adjust the enclosing element's zoom level where necessary.
    497  *
    498  * Because this method can be called really often, and zoom-level adjustment is slow,
    499  * we collect the elements to be processed and do them all later in a single deferred pass.
    500  */
    501 function imageOnLoad(e) {
    502     // normalize the quoted text parent if we're in a quoted text block, or else
    503     // normalize the parent message content element
    504     var parent = up(e.target, "elided-text") || up(e.target, "mail-message-content");
    505     if (!parent) {
    506         // sanity check. shouldn't really happen.
    507         return;
    508     }
    509 
    510     // if there was no previous work, schedule a new deferred job
    511     if (gImageLoadElements.length == 0) {
    512         window.setTimeout(handleAllImageOnLoads, 0);
    513     }
    514 
    515     // enqueue the work if it wasn't already enqueued
    516     if (gImageLoadElements.indexOf(parent) == -1) {
    517         gImageLoadElements.push(parent);
    518     }
    519 }
    520 
    521 // handle all deferred work from image onload events
    522 function handleAllImageOnLoads() {
    523     normalizeElementWidths(gImageLoadElements);
    524     measurePositions();
    525     // clear the queue so the next onload event starts a new job
    526     gImageLoadElements = [];
    527 }
    528 
    529 function blockImage(imageElement) {
    530     var src = imageElement.src;
    531     if (src.indexOf("http://") == 0 || src.indexOf("https://") == 0 ||
    532             src.indexOf("content://") == 0) {
    533         imageElement.setAttribute(BLOCKED_SRC_ATTR, src);
    534         imageElement.src = "data:";
    535     }
    536 }
    537 
    538 function setWideViewport() {
    539     var metaViewport = document.getElementById('meta-viewport');
    540     metaViewport.setAttribute('content', 'width=' + WIDE_VIEWPORT_WIDTH);
    541 }
    542 
    543 function restoreScrollPosition() {
    544     var scrollYPercent = window.mail.getScrollYPercent();
    545     if (scrollYPercent && document.body.offsetHeight > window.innerHeight) {
    546         document.body.scrollTop = Math.floor(scrollYPercent * document.body.offsetHeight);
    547     }
    548 }
    549 
    550 function onContentReady(event) {
    551     window.mail.onContentReady();
    552 }
    553 
    554 function setupContentReady() {
    555     var signalDiv;
    556 
    557     // PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER
    558     // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op
    559     // animation that immediately runs on page load. The app uses this as a signal that the
    560     // content is loaded and ready to draw, since WebView delays firing this event until the
    561     // layers are composited and everything is ready to draw.
    562     //
    563     // This code is conditionally enabled on JB+ by setting the ENABLE_CONTENT_READY flag.
    564     if (ENABLE_CONTENT_READY) {
    565         signalDiv = document.getElementById("initial-load-signal");
    566         signalDiv.addEventListener("webkitAnimationStart", onContentReady, false);
    567     }
    568 }
    569 
    570 // BEGIN Java->JavaScript handlers
    571 function measurePositions() {
    572     var overlayTops, overlayBottoms;
    573     var i;
    574     var len;
    575 
    576     var expandedBody, headerSpacer;
    577     var prevBodyBottom = 0;
    578     var expandedBodyDivs = document.querySelectorAll(".expanded > .mail-message-content");
    579 
    580     // N.B. offsetTop and offsetHeight of an element with the "zoom:" style applied cannot be
    581     // trusted.
    582 
    583     overlayTops = new Array(expandedBodyDivs.length + 1);
    584     overlayBottoms = new Array(expandedBodyDivs.length + 1);
    585     for (i = 0, len = expandedBodyDivs.length; i < len; i++) {
    586         expandedBody = expandedBodyDivs[i];
    587         headerSpacer = expandedBody.previousElementSibling;
    588         // addJavascriptInterface handler only supports string arrays
    589         overlayTops[i] = "" + prevBodyBottom;
    590         overlayBottoms[i] = "" + (getTotalOffset(headerSpacer).top + headerSpacer.offsetHeight);
    591         prevBodyBottom = getTotalOffset(expandedBody.nextElementSibling).top;
    592     }
    593     // add an extra one to mark the top/bottom of the last message footer spacer
    594     overlayTops[i] = "" + prevBodyBottom;
    595     overlayBottoms[i] = "" + document.documentElement.scrollHeight;
    596 
    597     window.mail.onWebContentGeometryChange(overlayTops, overlayBottoms);
    598 }
    599 
    600 function unblockImages(messageDomIds) {
    601     var i, j, images, imgCount, image, blockedSrc;
    602     for (j = 0, len = messageDomIds.length; j < len; j++) {
    603         var messageDomId = messageDomIds[j];
    604         var msg = document.getElementById(messageDomId);
    605         if (!msg) {
    606             console.log("can't unblock, no matching message for id: " + messageDomId);
    607             continue;
    608         }
    609         images = msg.getElementsByTagName("img");
    610         for (i = 0, imgCount = images.length; i < imgCount; i++) {
    611             image = images[i];
    612             blockedSrc = image.getAttribute(BLOCKED_SRC_ATTR);
    613             if (blockedSrc) {
    614                 image.src = blockedSrc;
    615                 image.removeAttribute(BLOCKED_SRC_ATTR);
    616             }
    617         }
    618     }
    619 }
    620 
    621 function setConversationHeaderSpacerHeight(spacerHeight) {
    622     var spacer = document.getElementById("conversation-header");
    623     if (!spacer) {
    624         console.log("can't set spacer for conversation header");
    625         return;
    626     }
    627     spacer.style.height = spacerHeight + "px";
    628     measurePositions();
    629 }
    630 
    631 function setMessageHeaderSpacerHeight(messageDomId, spacerHeight) {
    632     var spacer = document.querySelector("#" + messageDomId + " > .mail-message-header");
    633     if (!spacer) {
    634         console.log("can't set spacer for message with id: " + messageDomId);
    635         return;
    636     }
    637     spacer.style.height = spacerHeight + "px";
    638     measurePositions();
    639 }
    640 
    641 function setMessageBodyVisible(messageDomId, isVisible, spacerHeight,
    642         topBorderHeight, bottomBorderHeight) {
    643     var i, len;
    644     var visibility = isVisible ? "block" : "none";
    645     var messageDiv = document.querySelector("#" + messageDomId);
    646     var collapsibleDivs = document.querySelectorAll("#" + messageDomId + " > .collapsible");
    647     if (!messageDiv || collapsibleDivs.length == 0) {
    648         console.log("can't set body visibility for message with id: " + messageDomId);
    649         return;
    650     }
    651 
    652     // if the top border has changed, update the height of its spacer
    653     if (topBorderHeight > 0) {
    654         var border = messageDiv.previousElementSibling;
    655         if (!border) {
    656             console.log("can't set spacer for top border");
    657             return;
    658         }
    659         border.style.height = topBorderHeight + "px";
    660     }
    661 
    662     // if the bottom border has changed, update the height of its spacer
    663     if (bottomBorderHeight > 0) {
    664         var border = messageDiv.nextElementSibling;
    665         if (!border) {
    666             console.log("can't set spacer for bottom border");
    667             return;
    668         }
    669         border.style.height = bottomBorderHeight + "px";
    670     }
    671 
    672     messageDiv.classList.toggle("expanded");
    673     for (i = 0, len = collapsibleDivs.length; i < len; i++) {
    674         collapsibleDivs[i].style.display = visibility;
    675     }
    676 
    677     // revealing new content should trigger width normalization, since the initial render
    678     // skips collapsed and super-collapsed messages
    679     if (isVisible) {
    680         normalizeElementWidths(messageDiv.getElementsByClassName("mail-message-content"));
    681     }
    682 
    683     setMessageHeaderSpacerHeight(messageDomId, spacerHeight);
    684 }
    685 
    686 function replaceSuperCollapsedBlock(startIndex) {
    687     var parent, block, msg;
    688 
    689     block = document.querySelector(".mail-super-collapsed-block[index='" + startIndex + "']");
    690     if (!block) {
    691         console.log("can't expand super collapsed block at index: " + startIndex);
    692         return;
    693     }
    694     parent = block.parentNode;
    695     block.innerHTML = window.mail.getTempMessageBodies();
    696 
    697     // process the new block contents in one go before we pluck them out of the common ancestor
    698     processQuotedText(block, false /* showElided */);
    699     hideUnsafeImages(block.getElementsByClassName("mail-message-content"));
    700 
    701     msg = block.firstChild;
    702     while (msg) {
    703         parent.insertBefore(msg, block);
    704         msg = block.firstChild;
    705     }
    706     parent.removeChild(block);
    707     measurePositions();
    708 }
    709 
    710 function replaceMessageBodies(messageIds) {
    711     var i;
    712     var id;
    713     var msgContentDiv;
    714 
    715     for (i = 0, len = messageIds.length; i < len; i++) {
    716         id = messageIds[i];
    717         msgContentDiv = document.querySelector("#" + id + " > .mail-message-content");
    718         msgContentDiv.innerHTML = window.mail.getMessageBody(id);
    719         processQuotedText(msgContentDiv, true /* showElided */);
    720         hideUnsafeImages([msgContentDiv]);
    721     }
    722     measurePositions();
    723 }
    724 
    725 // handle the special case of adding a single new message at the end of a conversation
    726 function appendMessageHtml() {
    727     var msg = document.createElement("div");
    728     msg.innerHTML = window.mail.getTempMessageBodies();
    729     var body = msg.children[0];  // toss the outer div, it was just to render innerHTML into
    730     var border = msg.children[1]; // get the border spacer as well
    731     document.body.appendChild(body);
    732     document.body.appendChild(border);
    733     processQuotedText(msg, true /* showElided */);
    734     hideUnsafeImages(msg.getElementsByClassName("mail-message-content"));
    735     measurePositions();
    736 }
    737 
    738 function onScaleBegin(screenX, screenY) {
    739 //    console.log("JS got scaleBegin x/y=" + screenX + "/" + screenY);
    740     var focusX = screenX + document.body.scrollLeft;
    741     var focusY = screenY + document.body.scrollTop;
    742     var i, len;
    743     var msgDivs = document.getElementsByClassName("mail-message");
    744     var msgDiv, msgBodyDiv;
    745     var msgTop, msgDivTop, nextMsgTop;
    746     var initialH;
    747     var initialScale;
    748     var scaledOriginX, scaledOriginY;
    749     var translateX, translateY;
    750     var origin;
    751 
    752     gScaleInfo = undefined;
    753 
    754     for (i = 0, len = msgDivs.length; i < len; i++) {
    755         msgDiv = msgDivs[i];
    756         msgTop = nextMsgTop ? nextMsgTop : getTotalOffset(msgDiv).top;
    757         nextMsgTop = (i < len-1) ? getTotalOffset(msgDivs[i+1]).top : document.body.offsetHeight;
    758         if (focusY >= msgTop && focusY < nextMsgTop) {
    759             msgBodyDiv = msgDiv.children[1];
    760             initialScale = msgBodyDiv.getAttribute("data-initial-scale") || 1.0;
    761 
    762             msgDivTop = getTotalOffset(msgBodyDiv).top;
    763 
    764             // TODO: correct only for no initial translation
    765             // FIXME: wrong for initialScale > 1.0
    766             scaledOriginX = focusX / initialScale;
    767             scaledOriginY = (focusY - msgDivTop) / initialScale;
    768 
    769             // TODO: is this still needed?
    770             translateX = 0;
    771             translateY = 0;
    772 
    773             gScaleInfo = {
    774                 div: msgBodyDiv,
    775                 initialScale: initialScale,
    776                 initialScreenX: screenX,
    777                 initialScreenY: screenY,
    778                 originX: scaledOriginX,
    779                 originY: scaledOriginY,
    780                 translateX: translateX,
    781                 translateY: translateY,
    782                 initialH: getCachedValue(msgBodyDiv, "offsetHeight", "data-initial-height"),
    783                 minScale: Math.min(document.body.offsetWidth / msgBodyDiv.scrollWidth, 1.0),
    784                 currScale: initialScale,
    785                 currTranslateX: 0,
    786                 currTranslateY: 0
    787             };
    788 
    789             origin = scaledOriginX + "px " + scaledOriginY + "px";
    790             msgBodyDiv.classList.add("zooming-focused");
    791             msgBodyDiv.style.webkitTransformOrigin = origin;
    792             msgBodyDiv.style.webkitTransform = "scale3d(" + initialScale + "," + initialScale
    793                 + ",1) translate3d(" + translateX + "px," + translateY + "px,0)";
    794 //            console.log("scaleBegin, h=" + gScaleInfo.initialH + " origin='" + origin + "'");
    795             break;
    796         }
    797     }
    798 }
    799 
    800 function onScaleEnd(screenX, screenY) {
    801     var msgBodyDiv;
    802     var scale;
    803     var h;
    804     if (!gScaleInfo) {
    805         return;
    806     }
    807 
    808 //    console.log("JS got scaleEnd x/y=" + screenX + "/" + screenY);
    809     msgBodyDiv = gScaleInfo.div;
    810     scale = gScaleInfo.currScale;
    811     msgBodyDiv.style.webkitTransformOrigin = "0 0";
    812     // clear any translate
    813     // switching to a 2D transform here re-renders the fonts more clearly, but introduces
    814     // texture upload lag to any subsequent scale operation
    815     // TODO: conditionalize this based on device GPU performance and/or body size/complexity?
    816     if (true) {
    817         msgBodyDiv.style.webkitTransform = "scale(" + gScaleInfo.currScale + ")";
    818     } else {
    819         msgBodyDiv.style.webkitTransform = "scale3d(" + scale + "," + scale + ",1)";
    820     }
    821     h = gScaleInfo.initialH * scale;
    822 //    console.log("onScaleEnd set h=" + h);
    823     msgBodyDiv.style.height = h + "px";
    824 
    825     // Use saved translateX/Y rather than calculating from screenX/Y because screenX/Y values
    826     // from onScaleEnd only track focus of remaining pointers, which is not useful and leads
    827     // to a perceived jump.
    828     var deltaScrollX = (scale - 1) * gScaleInfo.originX - gScaleInfo.currTranslateX;
    829     var deltaScrollY = (scale - 1) * gScaleInfo.originY - gScaleInfo.currTranslateY;
    830 //    console.log("JS adjusting scroll by x/y=" + deltaScrollX + "/" + deltaScrollY);
    831 
    832     msgBodyDiv.classList.remove("zooming-focused");
    833     msgBodyDiv.setAttribute("data-initial-scale", scale);
    834 
    835     // TODO: is there a better way to make this more reliable?
    836     window.setTimeout(function() {
    837         window.scrollBy(deltaScrollX, deltaScrollY);
    838     }, 10);
    839 }
    840 
    841 function onScale(relativeScale, screenX, screenY) {
    842     var scale;
    843     var translateX, translateY;
    844     var transform;
    845 
    846     if (!gScaleInfo) {
    847         return;
    848     }
    849 
    850     scale = Math.max(gScaleInfo.initialScale * relativeScale, gScaleInfo.minScale);
    851     if (scale > 4.0) {
    852         scale = 4.0;
    853     }
    854     translateX = screenX - gScaleInfo.initialScreenX;
    855     translateY = screenY - gScaleInfo.initialScreenY;
    856     // TODO: clamp translation to prevent going beyond body edges
    857     gScaleInfo.currScale = scale;
    858     gScaleInfo.currTranslateX = translateX;
    859     gScaleInfo.currTranslateY = translateY;
    860     transform = "translate3d(" + translateX + "px," + translateY + "px,0) scale3d("
    861         + scale + "," + scale + ",1) translate3d(" + gScaleInfo.translateX + "px,"
    862         + gScaleInfo.translateY + "px,0)";
    863     gScaleInfo.div.style.webkitTransform = transform;
    864 //    console.log("JS got scale=" + scale + " x/y=" + screenX + "/" + screenY
    865 //        + " transform='" + transform + "'");
    866 }
    867 
    868 // END Java->JavaScript handlers
    869 
    870 // Do this first to ensure that the readiness signal comes through,
    871 // even if a stray exception later occurs.
    872 setupContentReady();
    873 
    874 collapseAllQuotedText();
    875 hideAllUnsafeImages();
    876 normalizeAllMessageWidths();
    877 //setWideViewport();
    878 restoreScrollPosition();
    879 measurePositions();
    880 
    881