script.js revision 02f9d18a54072db8d86c524f9c09e508092ddd7c
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 18var 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 22var gImageLoadElements = []; 23 24var gScaleInfo; 25 26/** 27 * Returns the page offset of an element. 28 * 29 * @param {Element} element The element to return the page offset for. 30 * @return {left: number, top: number} A tuple including a left and top value representing 31 * the page offset of the element. 32 */ 33function getTotalOffset(el) { 34 var result = { 35 left: 0, 36 top: 0 37 }; 38 var parent = el; 39 40 while (parent) { 41 result.left += parent.offsetLeft; 42 result.top += parent.offsetTop; 43 parent = parent.offsetParent; 44 } 45 46 return result; 47} 48 49/** 50 * Walks up the DOM starting at a given element, and returns an element that has the 51 * specified class name or null. 52 */ 53function up(el, className) { 54 var parent = el; 55 while (parent) { 56 if (parent.classList && parent.classList.contains(className)) { 57 break; 58 } 59 parent = parent.parentNode; 60 } 61 return parent || null; 62} 63 64function getCachedValue(div, property, attrName) { 65 var value; 66 if (div.hasAttribute(attrName)) { 67 value = div.getAttribute(attrName); 68 } else { 69 value = div[property]; 70 div.setAttribute(attrName, value); 71 } 72 return value; 73} 74 75function onToggleClick(e) { 76 toggleQuotedText(e.target); 77 measurePositions(); 78} 79 80function toggleQuotedText(toggleElement) { 81 var elidedTextElement = toggleElement.nextSibling; 82 var isHidden = getComputedStyle(elidedTextElement).display == 'none'; 83 toggleElement.innerHTML = isHidden ? MSG_HIDE_ELIDED : MSG_SHOW_ELIDED; 84 elidedTextElement.style.display = isHidden ? 'block' : 'none'; 85 86 // Revealing the elided text should normalize it to fit-width to prevent 87 // this message from blowing out the conversation width. 88 if (isHidden) { 89 normalizeElementWidths([elidedTextElement]); 90 } 91} 92 93function collapseAllQuotedText() { 94 processQuotedText(document.documentElement, false /* showElided */); 95} 96 97function processQuotedText(elt, showElided) { 98 var i; 99 var elements = elt.getElementsByClassName("elided-text"); 100 var elidedElement, toggleElement; 101 for (i = 0; i < elements.length; i++) { 102 elidedElement = elements[i]; 103 toggleElement = document.createElement("div"); 104 toggleElement.className = "mail-elided-text"; 105 toggleElement.innerHTML = MSG_SHOW_ELIDED; 106 toggleElement.onclick = onToggleClick; 107 elidedElement.style.display = 'none'; 108 elidedElement.parentNode.insertBefore(toggleElement, elidedElement); 109 if (showElided) { 110 toggleQuotedText(toggleElement); 111 } 112 } 113} 114 115function isConversationEmpty(bodyDivs) { 116 var i, len; 117 var msgBody; 118 var text; 119 120 // Check if given divs are empty (in appearance), and disable zoom if so. 121 for (i = 0, len = bodyDivs.length; i < len; i++) { 122 msgBody = bodyDivs[i]; 123 // use 'textContent' to exclude markup when determining whether bodies are empty 124 // (fall back to more expensive 'innerText' if 'textContent' isn't implemented) 125 text = msgBody.textContent || msgBody.innerText; 126 if (text.trim().length > 0) { 127 return false; 128 } 129 } 130 return true; 131} 132 133function normalizeAllMessageWidths() { 134 var expandedBodyDivs; 135 var metaViewport; 136 var contentValues; 137 var isEmpty; 138 139 if (!NORMALIZE_MESSAGE_WIDTHS) { 140 return; 141 } 142 143 expandedBodyDivs = document.querySelectorAll(".expanded > .mail-message-content"); 144 145 isEmpty = isConversationEmpty(expandedBodyDivs); 146 147 normalizeElementWidths(expandedBodyDivs); 148 149 // assemble a working <meta> viewport "content" value from the base value in the 150 // document, plus any dynamically determined options 151 metaViewport = document.getElementById("meta-viewport"); 152 contentValues = [metaViewport.getAttribute("content")]; 153 if (isEmpty) { 154 contentValues.push(metaViewport.getAttribute("data-zoom-off")); 155 } else { 156 contentValues.push(metaViewport.getAttribute("data-zoom-on")); 157 } 158 metaViewport.setAttribute("content", contentValues.join(",")); 159} 160 161/* 162 * Normalizes the width of all elements supplied to the document body's overall width. 163 * Narrower elements are zoomed in, and wider elements are zoomed out. 164 * This method is idempotent. 165 */ 166function normalizeElementWidths(elements) { 167 var i; 168 var el; 169 var documentWidth; 170 var newZoom, oldZoom; 171 172 if (!NORMALIZE_MESSAGE_WIDTHS) { 173 return; 174 } 175 176 documentWidth = document.body.offsetWidth; 177 178 for (i = 0; i < elements.length; i++) { 179 el = elements[i]; 180 oldZoom = el.style.zoom; 181 // reset any existing normalization 182 if (oldZoom) { 183 el.style.zoom = 1; 184 } 185 newZoom = documentWidth / el.scrollWidth; 186 el.style.zoom = newZoom; 187 } 188} 189 190function hideAllUnsafeImages() { 191 hideUnsafeImages(document.getElementsByClassName("mail-message-content")); 192} 193 194function hideUnsafeImages(msgContentDivs) { 195 var i, msgContentCount; 196 var j, imgCount; 197 var msgContentDiv, image; 198 var images; 199 var showImages; 200 for (i = 0, msgContentCount = msgContentDivs.length; i < msgContentCount; i++) { 201 msgContentDiv = msgContentDivs[i]; 202 showImages = msgContentDiv.classList.contains("mail-show-images"); 203 204 images = msgContentDiv.getElementsByTagName("img"); 205 for (j = 0, imgCount = images.length; j < imgCount; j++) { 206 image = images[j]; 207 rewriteRelativeImageSrc(image); 208 attachImageLoadListener(image); 209 // TODO: handle inline image attachments for all supported protocols 210 if (!showImages) { 211 blockImage(image); 212 } 213 } 214 } 215} 216 217/** 218 * Changes relative paths to absolute path by pre-pending the account uri 219 * @param {Element} imgElement Image for which the src path will be updated. 220 */ 221function rewriteRelativeImageSrc(imgElement) { 222 var src = imgElement.src; 223 224 // DOC_BASE_URI will always be a unique x-thread:// uri for this particular conversation 225 if (src.indexOf(DOC_BASE_URI) == 0 && (DOC_BASE_URI != CONVERSATION_BASE_URI)) { 226 // The conversation specifies a different base uri than the document 227 src = CONVERSATION_BASE_URI + src.substring(DOC_BASE_URI.length); 228 imgElement.src = src; 229 } 230}; 231 232 233function attachImageLoadListener(imageElement) { 234 // Reset the src attribute to the empty string because onload will only fire if the src 235 // attribute is set after the onload listener. 236 var originalSrc = imageElement.src; 237 imageElement.src = ''; 238 imageElement.onload = imageOnLoad; 239 imageElement.src = originalSrc; 240} 241 242/** 243 * Handle an onload event for an <img> tag. 244 * The image could be within an elided-text block, or at the top level of a message. 245 * When a new image loads, its new bounds may affect message or elided-text geometry, 246 * so we need to inspect and adjust the enclosing element's zoom level where necessary. 247 * 248 * Because this method can be called really often, and zoom-level adjustment is slow, 249 * we collect the elements to be processed and do them all later in a single deferred pass. 250 */ 251function imageOnLoad(e) { 252 // normalize the quoted text parent if we're in a quoted text block, or else 253 // normalize the parent message content element 254 var parent = up(e.target, "elided-text") || up(e.target, "mail-message-content"); 255 if (!parent) { 256 // sanity check. shouldn't really happen. 257 return; 258 } 259 260 // if there was no previous work, schedule a new deferred job 261 if (gImageLoadElements.length == 0) { 262 window.setTimeout(handleAllImageOnLoads, 0); 263 } 264 265 // enqueue the work if it wasn't already enqueued 266 if (gImageLoadElements.indexOf(parent) == -1) { 267 gImageLoadElements.push(parent); 268 } 269} 270 271// handle all deferred work from image onload events 272function handleAllImageOnLoads() { 273 normalizeElementWidths(gImageLoadElements); 274 measurePositions(); 275 // clear the queue so the next onload event starts a new job 276 gImageLoadElements = []; 277} 278 279function blockImage(imageElement) { 280 var src = imageElement.src; 281 if (src.indexOf("http://") == 0 || src.indexOf("https://") == 0 || 282 src.indexOf("content://") == 0) { 283 imageElement.setAttribute(BLOCKED_SRC_ATTR, src); 284 imageElement.src = "data:"; 285 } 286} 287 288function setWideViewport() { 289 var metaViewport = document.getElementById('meta-viewport'); 290 metaViewport.setAttribute('content', 'width=' + WIDE_VIEWPORT_WIDTH); 291} 292 293function restoreScrollPosition() { 294 var scrollYPercent = window.mail.getScrollYPercent(); 295 if (scrollYPercent && document.body.offsetHeight > window.innerHeight) { 296 document.body.scrollTop = Math.floor(scrollYPercent * document.body.offsetHeight); 297 } 298} 299 300function onContentReady(event) { 301 window.mail.onContentReady(); 302} 303 304function setupContentReady() { 305 var signalDiv; 306 307 // PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER 308 // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op 309 // animation that immediately runs on page load. The app uses this as a signal that the 310 // content is loaded and ready to draw, since WebView delays firing this event until the 311 // layers are composited and everything is ready to draw. 312 // 313 // This code is conditionally enabled on JB+ by setting the ENABLE_CONTENT_READY flag. 314 if (ENABLE_CONTENT_READY) { 315 signalDiv = document.getElementById("initial-load-signal"); 316 signalDiv.addEventListener("webkitAnimationStart", onContentReady, false); 317 } 318} 319 320// BEGIN Java->JavaScript handlers 321function measurePositions() { 322 var overlayTops, overlayBottoms; 323 var i; 324 var len; 325 326 var expandedBody, headerSpacer; 327 var prevBodyBottom = 0; 328 var expandedBodyDivs = document.querySelectorAll(".expanded > .mail-message-content"); 329 330 // N.B. offsetTop and offsetHeight of an element with the "zoom:" style applied cannot be 331 // trusted. 332 333 overlayTops = new Array(expandedBodyDivs.length + 1); 334 overlayBottoms = new Array(expandedBodyDivs.length + 1); 335 for (i = 0, len = expandedBodyDivs.length; i < len; i++) { 336 expandedBody = expandedBodyDivs[i]; 337 headerSpacer = expandedBody.previousElementSibling; 338 // addJavascriptInterface handler only supports string arrays 339 overlayTops[i] = "" + prevBodyBottom; 340 overlayBottoms[i] = "" + (getTotalOffset(headerSpacer).top + headerSpacer.offsetHeight); 341 prevBodyBottom = getTotalOffset(expandedBody.nextElementSibling).top; 342 } 343 // add an extra one to mark the top/bottom of the last message footer spacer 344 overlayTops[i] = "" + prevBodyBottom; 345 overlayBottoms[i] = "" + document.body.offsetHeight; 346 347 window.mail.onWebContentGeometryChange(overlayTops, overlayBottoms); 348} 349 350function unblockImages(messageDomIds) { 351 var i, j, images, imgCount, image, blockedSrc; 352 for (j = 0, len = messageDomIds.length; j < len; j++) { 353 var messageDomId = messageDomIds[j]; 354 var msg = document.getElementById(messageDomId); 355 if (!msg) { 356 console.log("can't unblock, no matching message for id: " + messageDomId); 357 continue; 358 } 359 images = msg.getElementsByTagName("img"); 360 for (i = 0, imgCount = images.length; i < imgCount; i++) { 361 image = images[i]; 362 blockedSrc = image.getAttribute(BLOCKED_SRC_ATTR); 363 if (blockedSrc) { 364 image.src = blockedSrc; 365 image.removeAttribute(BLOCKED_SRC_ATTR); 366 } 367 } 368 } 369} 370 371function setConversationHeaderSpacerHeight(spacerHeight) { 372 var spacer = document.getElementById("conversation-header"); 373 if (!spacer) { 374 console.log("can't set spacer for conversation header"); 375 return; 376 } 377 spacer.style.height = spacerHeight + "px"; 378 measurePositions(); 379} 380 381function setMessageHeaderSpacerHeight(messageDomId, spacerHeight) { 382 var spacer = document.querySelector("#" + messageDomId + " > .mail-message-header"); 383 if (!spacer) { 384 console.log("can't set spacer for message with id: " + messageDomId); 385 return; 386 } 387 spacer.style.height = spacerHeight + "px"; 388 measurePositions(); 389} 390 391function setMessageBodyVisible(messageDomId, isVisible, spacerHeight) { 392 var i, len; 393 var visibility = isVisible ? "block" : "none"; 394 var messageDiv = document.querySelector("#" + messageDomId); 395 var collapsibleDivs = document.querySelectorAll("#" + messageDomId + " > .collapsible"); 396 if (!messageDiv || collapsibleDivs.length == 0) { 397 console.log("can't set body visibility for message with id: " + messageDomId); 398 return; 399 } 400 messageDiv.classList.toggle("expanded"); 401 for (i = 0, len = collapsibleDivs.length; i < len; i++) { 402 collapsibleDivs[i].style.display = visibility; 403 } 404 405 // revealing new content should trigger width normalization, since the initial render 406 // skips collapsed and super-collapsed messages 407 if (isVisible) { 408 normalizeElementWidths(messageDiv.getElementsByClassName("mail-message-content")); 409 } 410 411 setMessageHeaderSpacerHeight(messageDomId, spacerHeight); 412} 413 414function replaceSuperCollapsedBlock(startIndex) { 415 var parent, block, msg; 416 417 block = document.querySelector(".mail-super-collapsed-block[index='" + startIndex + "']"); 418 if (!block) { 419 console.log("can't expand super collapsed block at index: " + startIndex); 420 return; 421 } 422 parent = block.parentNode; 423 block.innerHTML = window.mail.getTempMessageBodies(); 424 425 // process the new block contents in one go before we pluck them out of the common ancestor 426 processQuotedText(block, false /* showElided */); 427 hideUnsafeImages(block.getElementsByClassName("mail-message-content")); 428 429 msg = block.firstChild; 430 while (msg) { 431 parent.insertBefore(msg, block); 432 msg = block.firstChild; 433 } 434 parent.removeChild(block); 435 measurePositions(); 436} 437 438function replaceMessageBodies(messageIds) { 439 var i; 440 var id; 441 var msgContentDiv; 442 443 for (i = 0, len = messageIds.length; i < len; i++) { 444 id = messageIds[i]; 445 msgContentDiv = document.querySelector("#" + id + " > .mail-message-content"); 446 msgContentDiv.innerHTML = window.mail.getMessageBody(id); 447 processQuotedText(msgContentDiv, true /* showElided */); 448 hideUnsafeImages([msgContentDiv]); 449 } 450 measurePositions(); 451} 452 453// handle the special case of adding a single new message at the end of a conversation 454function appendMessageHtml() { 455 var msg = document.createElement("div"); 456 msg.innerHTML = window.mail.getTempMessageBodies(); 457 msg = msg.children[0]; // toss the outer div, it was just to render innerHTML into 458 document.body.appendChild(msg); 459 processQuotedText(msg, true /* showElided */); 460 hideUnsafeImages(msg.getElementsByClassName("mail-message-content")); 461 measurePositions(); 462} 463 464function onScaleBegin(screenX, screenY) { 465// console.log("JS got scaleBegin x/y=" + screenX + "/" + screenY); 466 var focusX = screenX + document.body.scrollLeft; 467 var focusY = screenY + document.body.scrollTop; 468 var i, len; 469 var msgDivs = document.getElementsByClassName("mail-message"); 470 var msgDiv, msgBodyDiv; 471 var msgTop, msgDivTop, nextMsgTop; 472 var initialH; 473 var initialScale; 474 var scaledOriginX, scaledOriginY; 475 var translateX, translateY; 476 var origin; 477 478 gScaleInfo = undefined; 479 480 for (i = 0, len = msgDivs.length; i < len; i++) { 481 msgDiv = msgDivs[i]; 482 msgTop = nextMsgTop ? nextMsgTop : getTotalOffset(msgDiv).top; 483 nextMsgTop = (i < len-1) ? getTotalOffset(msgDivs[i+1]).top : document.body.offsetHeight; 484 if (focusY >= msgTop && focusY < nextMsgTop) { 485 msgBodyDiv = msgDiv.children[1]; 486 initialScale = msgBodyDiv.getAttribute("data-initial-scale") || 1.0; 487 488 msgDivTop = getTotalOffset(msgBodyDiv).top; 489 490 scaledOriginX = focusX;// / initialScale; 491 scaledOriginY = (focusY - msgDivTop);// / initialScale; 492 493 translateX = scaledOriginX * (initialScale - 1.0) / initialScale; 494 translateY = scaledOriginY * (initialScale - 1.0) / initialScale; 495 496 gScaleInfo = { 497 div: msgBodyDiv, 498 divTop: msgDivTop, 499 initialScale: initialScale, 500 initialX: focusX, 501 initialY: focusY, 502 translateX: translateX, 503 translateY: translateY, 504 initialH: getCachedValue(msgBodyDiv, "offsetHeight", "data-initial-height"), 505 minScale: Math.min(document.body.offsetWidth / msgBodyDiv.scrollWidth, 1.0), 506 currScale: initialScale 507 }; 508 509 origin = scaledOriginX + "px " + scaledOriginY + "px"; 510 msgBodyDiv.classList.add("zooming-focused"); 511 msgBodyDiv.style.webkitTransformOrigin = origin; 512 msgBodyDiv.style.webkitTransform = "scale3d(" + initialScale + "," + initialScale 513 + ",1) translate3d(" + translateX + "px," + translateY + "px,0)"; 514// console.log("scaleBegin, h=" + gScaleInfo.initialH + " origin='" + origin + "'"); 515 break; 516 } 517 } 518} 519 520function onScaleEnd(screenX, screenY) { 521 var msgBodyDiv; 522 var scale; 523 var h; 524 if (!gScaleInfo) { 525 return; 526 } 527 528// console.log("JS got scaleEnd x/y=" + screenX + "/" + screenY); 529 msgBodyDiv = gScaleInfo.div; 530 scale = gScaleInfo.currScale; 531 msgBodyDiv.style.webkitTransformOrigin = "0 0"; 532 // clear any translate 533 msgBodyDiv.style.webkitTransform = "scale3d(" + scale + "," + scale + ",1)"; 534 // switching to a 2D transform here re-renders the fonts more clearly, but introduces 535 // texture upload lag to any subsequent scale operation 536 //msgBodyDiv.style.webkitTransform = "scale(" + gScaleInfo.currScale + ")"; 537 h = gScaleInfo.initialH * scale; 538// console.log("onScaleEnd set h=" + h); 539 msgBodyDiv.style.height = h + "px"; 540 msgBodyDiv.classList.remove("zooming-focused"); 541 msgBodyDiv.setAttribute("data-initial-scale", scale); 542} 543 544function onScale(relativeScale, screenX, screenY) { 545 var focusX, focusY; 546 var scale; 547 var translateX, translateY; 548 var transform; 549 550 if (!gScaleInfo) { 551 return; 552 } 553 focusX = screenX + document.body.scrollLeft; 554 focusY = screenY + document.body.scrollTop; 555 556 scale = Math.max(gScaleInfo.initialScale * relativeScale, gScaleInfo.minScale); 557 if (scale > 4.0) { 558 scale = 4.0; 559 } 560 gScaleInfo.currScale = scale; 561 translateX = focusX - gScaleInfo.initialX; 562 translateY = focusY - gScaleInfo.initialY; 563 transform = "translate3d(" + translateX + "px," + translateY + "px,0) scale3d(" 564 + scale + "," + scale + ",1) translate3d(" + gScaleInfo.translateX + "px," 565 + gScaleInfo.translateY + "px,0)"; 566 gScaleInfo.div.style.webkitTransform = transform; 567// console.log("JS got scale=" + relativeScale + " x/y=" + screenX + "/" + screenY 568// + " transform='" + transform + "'"); 569} 570 571// END Java->JavaScript handlers 572 573// Do this first to ensure that the readiness signal comes through, 574// even if a stray exception later occurs. 575setupContentReady(); 576 577collapseAllQuotedText(); 578hideAllUnsafeImages(); 579normalizeAllMessageWidths(); 580//setWideViewport(); 581restoreScrollPosition(); 582measurePositions(); 583 584