Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
751 lines
31 KiB
JavaScript
Executable file
751 lines
31 KiB
JavaScript
Executable file
define(['shared/js/gesture_detector'], function(GestureDetector) {
|
|
|
|
'use strict';
|
|
|
|
/**
|
|
* Style tag to put in the header of the body. We currently only support inline
|
|
* styles in general, so these are primarily overrides and defaults.
|
|
*/
|
|
var DEFAULT_STYLE_TAG =
|
|
'<style type="text/css">\n' +
|
|
// ## blockquote
|
|
// blockquote per html5: before: 1em, after: 1em, start: 4rem, end: 4rem
|
|
'blockquote {' +
|
|
'margin: 0; ' +
|
|
// so, this is quoting styling, which makes less sense to have in here.
|
|
'-moz-border-start: 0.2rem solid gray;' +
|
|
// padding-start isn't a thing yet, somehow.
|
|
'padding: 0; -moz-padding-start: 0.5rem; ' +
|
|
'}\n' +
|
|
// Give the layout engine an upper-bound on the width that's arguably
|
|
// much wider than anyone should find reasonable, but might save us from
|
|
// super pathological cases.
|
|
'html, body { max-width: 120rem; word-wrap: break-word;' +
|
|
// don't let the html/body grow the scrollable area. Also, it's not clear
|
|
// overflow: hidden actually works in either of these cases, but I did most of
|
|
// the development and testing where things worked with the overflow: hidden
|
|
// present and I'm worried about removing it now.
|
|
' overflow: hidden; padding: 0; margin: 0; }\n' +
|
|
// pre messes up wrapping very badly if left to its own devices
|
|
'pre { white-space: pre-wrap; word-wrap: break-word; }\n' +
|
|
'.moz-external-link { color: #00aac5; cursor: pointer; }\n' +
|
|
'</style>';
|
|
|
|
/**
|
|
* Tweakable display settings for timings. If you want to mess with these
|
|
* values from the debugger, do requirejs('iframe_shims').iframeShimsOpts.
|
|
*
|
|
* All current poll timeouts (circa Sep 19, 2014) are ballpark figures arrived
|
|
* at on a Flame device. We could probably tighten things up if need be.
|
|
*/
|
|
var iframeShimsOpts = {
|
|
/**
|
|
* What is the minimum delay between changing the transform setting? You
|
|
* might think that we want this low, but because we experience memory-spikes
|
|
* if we modify the transform from a setTimeout, we currently want this
|
|
* to be short enough that a human would be unlikely to actually re-trigger
|
|
* while this is active. It's handy to keep around to turn it way up so that
|
|
* we can reproduce the setTimeout problem for debugging, however.
|
|
*/
|
|
zoomDelayMS: 200,
|
|
/**
|
|
* What should our initial scale-factor be? If 1, it's 100%. If null, we use
|
|
* the fit-page-width value.
|
|
*/
|
|
initialScale: null,
|
|
/**
|
|
* How many times should we poll the dimensions of the HTML iframe before
|
|
* ceasing? This is used both for initial display and after "display external
|
|
* images" or "display embedded images" is triggered.
|
|
*/
|
|
resizeLimit: 4,
|
|
/**
|
|
* After first creating the document, how long should we wait before we start
|
|
* to poll? Note that the "load" event doesn't work for us and
|
|
* "DOMContentLoaded" turns out to be too early. Even though we forbid remote
|
|
* resources, it seems like our fonts or something can still need to
|
|
* asynchronously load or the HTML5 parser no longer synchronously lays
|
|
* everything out for us.
|
|
*/
|
|
initialResizePollIntervalMS: 200,
|
|
/**
|
|
* If we polled and there was no change in dimensions, how long should we wait
|
|
* before our next poll? The idea is you might make this shorter in order to
|
|
* make sure we respond sooner / faster.
|
|
*/
|
|
noResizePollIntervalMS: 250,
|
|
/**
|
|
* If we polled and there was a change in dimensions, how long should we wait
|
|
* before our next poll? The idea is you might make this longer so as to
|
|
* avoid churn if there is something going on that would affect sizing.
|
|
*/
|
|
didResizePollIntervalMS: 300,
|
|
/**
|
|
* How long should we wait until after we get the last picture "load" event
|
|
* before polling? Note that in this case we will have reset our resize count
|
|
* back to 0 so resizeLimit will need to be hit again. The waiting is
|
|
* accomplished by constantly resetting the timeout, so extremely small values
|
|
* are dangerous here. Also, experience has shown that when we previously
|
|
* tried to update our size immediately or near-immediately getting the final
|
|
* load event, we still would be too early.
|
|
*/
|
|
pictureDelayPollIntervalMS: 200
|
|
};
|
|
|
|
/**
|
|
* Logic to help with creating, populating, and handling events involving our
|
|
* HTML message-disply iframes.
|
|
*
|
|
* ## UX Goals ##
|
|
*
|
|
* We want a continuous scrolling experience. The message's envelope and the
|
|
* HTML body should scroll continuously.
|
|
*
|
|
* Pinch-and-zoom: We want the user to be able to zoom in and out on the message
|
|
* in a responsive fashion without crashing the app. We also want to start
|
|
* with fit-to-page-width because when the email is wider than the screen it
|
|
* tends to look stupid.
|
|
*
|
|
* ## Security ##
|
|
*
|
|
* All HTML content is passed through a white-list-based sanitization process,
|
|
* but we still want the iframe so that:
|
|
*
|
|
* - We can guarantee the content can't escape out into the rest of the page.
|
|
* - We can both avoid the content being influenced by our stylesheets as well
|
|
* as to allow the content to use inline "style" tags without any risk to our
|
|
* styling.
|
|
*
|
|
* Our iframe sandbox attributes (not) specified and rationale are as follows.
|
|
* Note that "NO" means we don't specify the string in our sandbox.
|
|
* - "allow-same-origin": YES. We do this because in order to touch the
|
|
* contentDocument we need to live in the same origin. Because scripts are
|
|
* not enabled in the iframe this is not believed to have any meaningful
|
|
* impact.
|
|
*
|
|
* In the future when we are able to do nested APZ stuff, what we
|
|
* will likely do is have two layers of iframes. The outer mozbrowser iframe
|
|
* will have its own origin but be running (only) our code. It will talk to
|
|
* us via postMessage. Then it will have a sandboxed iframe where script is
|
|
* disabled but that lives in the same origin. So our code in that origin
|
|
* can then poke at things as needed.
|
|
*
|
|
* - "allow-scripts": NO. We never ever want to let scripts from an email
|
|
* run. And since we are setting "allow-same-origin", even if we did want
|
|
* to allow scripts we *must not* while that setting is on. Our CSP should
|
|
* limit the use of scripts if the iframe has the same origin as us since
|
|
* everything in the iframe should qualify as
|
|
*
|
|
* - "allow-top-navigation": NO. The iframe should not navigate if the user
|
|
* clicks on a link. Note that the current plan is to just capture the
|
|
* click event and trigger the browse event ourselves so we can show them the
|
|
* URL, so this is just extra protection.
|
|
*
|
|
* - "allow-forms": NO. We already sanitize forms out, so this is just extra
|
|
* protection.
|
|
*
|
|
* - "allow-popups": NO. We would never want this, but it also shouldn't be
|
|
* possible to even try to trigger this (scripts are disabled and sanitized,
|
|
* links are sanitized to forbid link targets as well as being nerfed), so
|
|
* this is also just extra protection.
|
|
*
|
|
* ## Platform Limitations: We Got'em! ##
|
|
*
|
|
* ### Seamless iframes ###
|
|
*
|
|
* Gecko does not support seamless iframes, so we have to manually make sure
|
|
* that we set the iframe's outer size to what its inner size is. Because
|
|
* layout is asynchronous (even in the document.write case, apparently), we end
|
|
* up polling after any notable event that might affect layout.
|
|
*
|
|
* I did experiment with the gecko-specific 'overflow' event a bit. Although I
|
|
* suspect there were complicating factors, I do believe I ran into trouble with
|
|
* it since it is an event that is only generated each time you transition from
|
|
* overflow and back to underflow. So if you get an overflow event but didn't
|
|
* actually cause yourself to go back to underflow (like if you have weird CSS
|
|
* maybe doing something like setting a width to 105% or something?), you won't
|
|
* get another overflow event.
|
|
*
|
|
* ### Pinch-and-Zoom ###
|
|
*
|
|
* Gecko supports Asynchronous Pan-and-Zoom (APZ), but we can't use it for our
|
|
* HTML pages right now because it can only be used for the root of an
|
|
* app/browser window. And there is no support for nested subprocesses yet.
|
|
* When that stuff happens, we want to just use that instead of doing manual
|
|
* pinchy-zoomy support.
|
|
*
|
|
* We fake some level of usable pinch-zoom by using a "transform: scale()" on
|
|
* our iframe. Because the transform is a painting thing and not a layout thing
|
|
* we have to wrap the iframe in a "viewport" div that provides our effective
|
|
* DOM size for scrolling. We could maybe use better nomenclature for this and
|
|
* maybe even stop nesting the iframe in the viewport. (The current structure
|
|
* is somewhat historical from when the viewport div actually was clipping the
|
|
* iframe.)
|
|
*
|
|
* For example, let's say our iframe is internally 580px by 1000px but we are
|
|
* displaying it at 50% scale so it's 290px by 500px. In that case the iframe's
|
|
* size still needs to be 580px by 1000px, but the viewport needs to be 290px by
|
|
* 500px so that the scrolling works out right. Otherwise you end up with lots
|
|
* of white space at the right and bottom.
|
|
*
|
|
* Likewise if we are zooming it to 200% we need the viewport's dimensions to be
|
|
* doubled so that there is the extra space to scroll into.
|
|
*
|
|
* ### Transform Performance / Memory Limitations ###
|
|
*
|
|
* We can't actually mess with "transform: scale()" in realtime. This is
|
|
* primarily because it results in memory spikes that can get our process killed
|
|
* as the graphics subsystem's logic glitches and ends up allocating graphics
|
|
* buffers for the entirety of the HTML document, even the parts not on the
|
|
* screen. But a secondary concern is that especially when it's drawing too
|
|
* much, it can take a very long time to scale.
|
|
*
|
|
* So we've implemented a "quantized" scaling approach where we have four zoom
|
|
* levels: "fit-to-width" (which is <= 1), 100%, 150%, and 200%. Pinching to
|
|
* zoom in moves you to the right in the list, pinching to zoom out moves you
|
|
* out in the list.
|
|
*
|
|
* We use the shared gesture_detector code to figure out what's going on.
|
|
* Specifically, once the scale in absolute terms is clearly a zoom in or a zoom
|
|
* out, we trigger the scale change and then ignore the rest of the gesture
|
|
* until a new gesture occurs. This is arguably intuitive, but more importantly
|
|
* it avoids the problems we had in the past where you could just absurdly
|
|
* oscillate your pinchers and kill the app as we swamped the system with a
|
|
* gazillion transforms.
|
|
*
|
|
*
|
|
* ## Email types and Pinchy-Zoomy time ##
|
|
*
|
|
* There are two types of HTML e-mails:
|
|
*
|
|
* 1) E-mails written by humans which are basically unstructured prose plus
|
|
* quoting. The biggest problems these face are deep quoting causing
|
|
* blockquote padding to cause text to have very little screen real estate.
|
|
*
|
|
* 2) Newsletter style e-mails which are structured and may have multiple
|
|
* columns, grids of images and stuff like that. They historically have
|
|
* tended to assume a size of about 600px. However, it's increasingly common
|
|
* to be smart and use media queries. Unfortunately, we don't support media
|
|
* queries right now and so it's very likely we'll end up in the desktop
|
|
* case.
|
|
*
|
|
* We originally treated these types of mails differently, but over time it
|
|
* became clear that this was not a great strategy, especially since showing
|
|
* external images/etc. could push a "normal" email into being a "newsletter"
|
|
* email. We also intentionally would trigger a layout with relaxed constraints
|
|
* then try and tighten them up.
|
|
*
|
|
* Our (new) strategy is to create the iframe so that it fits in the width we
|
|
* have available. On flame devices that's 290px right now, though the actual
|
|
* size is discovered at runtime and doesn't matter.
|
|
*
|
|
* As discussed above, we poll the scrollWidth and scrollHeight for a while to
|
|
* make sure that it stabilizes. The trick is that if something is a newsletter
|
|
* it will end up wanting to be wider than our base/screen 290px. We will
|
|
* detect this and update our various dimensions, including our "fit-to-width"
|
|
* scale. Since we pick 100% or the computed fit-to-width scale, whichever is
|
|
* smaller, the non-newsletter case is just us using a fit-to-width zoom factor
|
|
* that just happens to be 100%. The newsletter case is when fit-to-width is
|
|
* less than 100%.
|
|
*
|
|
* ## Bugs / Doc Links ##
|
|
*
|
|
* - Font-inflation is a thing. It's not clear it affects us:
|
|
* http://jwir3.wordpress.com/2012/07/30/font-inflation-fennec-and-you/
|
|
*
|
|
* - iframe "seamless" doesn't work, so we manually need to poke stuff:
|
|
* https://bugzilla.mozilla.org/show_bug.cgi?id=80713
|
|
*
|
|
* Uh, the ^ stuff below should really be @, but it's my jstut syntax that
|
|
* gjslint simply hates, so...
|
|
*
|
|
* ^args[
|
|
* ^param[htmlStr]
|
|
* ^param[parentNode]{
|
|
* The (future) parent node of the iframe.
|
|
* }
|
|
* ^param[adjacentNode ^oneof[null HTMLNode]]{
|
|
* insertBefore semantics.
|
|
* }
|
|
* ^param[linkClickHandler ^func[
|
|
* ^args[
|
|
* ^param[event]{
|
|
* }
|
|
* ^param[linkNode HTMLElement]{
|
|
* The actual link HTML element
|
|
* }
|
|
* ^param[linkUrl String]{
|
|
* The URL that would be navigated to.
|
|
* }
|
|
* ^param[linkText String]{
|
|
* The text associated with the link.
|
|
* }
|
|
* ]
|
|
* ]]{
|
|
* The function to invoke when (sanitized) hyperlinks are clicked on.
|
|
* Currently, the links are always 'a' tags, but we might support image
|
|
* maps in the future. (Or permanently rule them out.)
|
|
* }
|
|
* ]
|
|
*/
|
|
function createAndInsertIframeForContent(htmlStr, scrollContainer,
|
|
parentNode, beforeNode,
|
|
interactiveMode,
|
|
clickHandler) {
|
|
// We used to care about running in Firefox nightly. This was a fudge-factor
|
|
// to account for its stupid scroll-bars that could not be escaped. If you
|
|
// are using nightly, maybe it makes sense to turn this back up. Or maybe we
|
|
// leave this zero and style the scrollbars to be overlays in b2g. Who knows.
|
|
var scrollPad = 0;
|
|
|
|
var viewportWidth = parentNode.offsetWidth - scrollPad;
|
|
var viewport = document.createElement('div');
|
|
viewport.setAttribute(
|
|
'style',
|
|
'padding: 0; border-width: 0; margin: 0; ' +
|
|
//'position: relative; ' +
|
|
'overflow: hidden;');
|
|
viewport.style.width = viewportWidth + 'px';
|
|
// leave height unsized for now.
|
|
|
|
var iframe = document.createElement('iframe');
|
|
|
|
iframe.setAttribute('sandbox', 'allow-same-origin');
|
|
// Styling!
|
|
iframe.setAttribute(
|
|
'style',
|
|
// no border! no padding/margins.
|
|
'padding: 0; border-width: 0; margin: 0; ' +
|
|
// I don't think this actually stops the iframe from being internally
|
|
// scrolly, but I wouldn't remove this without some testing...
|
|
'overflow: hidden; ' +
|
|
// When scaling, use the top-left for math sanity.
|
|
'transform-origin: top ' +
|
|
(document.documentElement.dir === 'rtl' ? 'right' : 'left') + '; ' +
|
|
// The iframe does not want to process its own clicks! that's what
|
|
// bindSanitizedClickHandler is for!
|
|
'pointer-events: none;');
|
|
if (iframeShimsOpts.tapTransform) {
|
|
iframe.style.transform = 'scale(1)';
|
|
}
|
|
// try and get the page to size itself to our actually available space.
|
|
iframe.style.width = viewportWidth + 'px';
|
|
|
|
// We need to be linked into the DOM tree to be able to write to our document
|
|
// and have CSS and improtant things like that work.
|
|
viewport.appendChild(iframe);
|
|
parentNode.insertBefore(viewport, beforeNode);
|
|
|
|
// we want this fully synchronous so we can know the size of the document
|
|
iframe.contentDocument.open();
|
|
iframe.contentDocument.write('<!doctype html><html><head>');
|
|
iframe.contentDocument.write(DEFAULT_STYLE_TAG);
|
|
iframe.contentDocument.write('</head><body>');
|
|
// (currently our sanitization only generates a body payload...)
|
|
iframe.contentDocument.write(htmlStr);
|
|
iframe.contentDocument.write('</body>');
|
|
iframe.contentDocument.close();
|
|
var iframeBody = iframe.contentDocument.body;
|
|
|
|
// NOTE. This has gone through some historical iterations here AKA is
|
|
// evolved. Technically, getBoundingClientRect() may be superior since it can
|
|
// have fractional parts. I believe I tried using it with
|
|
// iframe.contentDocument.documentElement and it ended up betraying me by
|
|
// reporting clientWidth/clientHeight instead of scrollWidth, whereas
|
|
// scrollWidth/scrollHeight worked better. However I was trying a lot of
|
|
// things; I might just have been confused by some APZ glitches where panning
|
|
// right would not work immediately after zooming and you'd have to pan left
|
|
// first in order to pan all the way to the newly expaned right. What we know
|
|
// right now is this gives the desired behaviour sizing behaviour.
|
|
var scrollWidth = iframeBody.scrollWidth;
|
|
var scrollHeight = iframeBody.scrollHeight;
|
|
|
|
// fit-to-width scale.
|
|
var baseScale = Math.min(1, viewportWidth / scrollWidth),
|
|
// If there's an initial scale, use that, otherwise fall back to the base
|
|
// (fit-to-width) scale
|
|
lastRequestedScale = iframeShimsOpts.initialScale || baseScale,
|
|
scale = lastRequestedScale,
|
|
lastDoubleTapScale = scale,
|
|
scaleMode = 0;
|
|
|
|
viewport.style.width = Math.ceil(scrollWidth * scale) + 'px';
|
|
viewport.style.height = Math.ceil(scrollHeight * scale) + 'px';
|
|
|
|
// setting iframe.style.height is not sticky, so be heavy-handed.
|
|
// Also, do not set overflow: hidden since we are already clipped by our
|
|
// viewport or our containing card and Gecko slows down a lot because of the
|
|
// extra clipping.
|
|
iframe.style.width = scrollWidth + 'px';
|
|
|
|
var resizeFrame = function(why) {
|
|
if (why === 'initial' || why === 'poll') {
|
|
scrollWidth = iframeBody.scrollWidth;
|
|
scrollHeight = iframeBody.scrollHeight;
|
|
// the baseScale will almost certainly have changed
|
|
var oldBaseScale = baseScale;
|
|
baseScale = Math.min(1, viewportWidth / scrollWidth);
|
|
if (scale === oldBaseScale) {
|
|
scale = baseScale;
|
|
}
|
|
iframe.style.width = scrollWidth + 'px';
|
|
console.log('iframe_shims: recalculating height / width because', why,
|
|
'sw', scrollWidth, 'sh', scrollHeight, 'bs', baseScale);
|
|
}
|
|
console.log('iframe_shims: scale:', scale);
|
|
iframe.style.transform = 'scale(' + scale + ')';
|
|
iframe.style.height =
|
|
((scrollHeight * Math.max(1, scale)) + scrollPad) + 'px';
|
|
viewport.style.width = Math.ceil(scrollWidth * scale) + 'px';
|
|
viewport.style.height = (Math.ceil(scrollHeight * scale) + scrollPad) +
|
|
'px';
|
|
};
|
|
resizeFrame('initial');
|
|
|
|
var activeZoom = false, lastCenterX, lastCenterY;
|
|
/**
|
|
* Zoom to the given scale, eventually. If we are actively zooming or have
|
|
* recently zoomed and need for various async things to catch up, we will
|
|
* wait a bit before actually zooming to that scale. We latch the most recent
|
|
* value in all cases.
|
|
*/
|
|
var zoomFrame = function(newScale, centerX, centerY) {
|
|
// There is nothing to do if we are actually already at this scale level.
|
|
// (Note that there still is something to do if newScale ===
|
|
// lastRequestedScale, though!)
|
|
if (newScale === scale) {
|
|
return;
|
|
}
|
|
lastRequestedScale = newScale;
|
|
lastCenterX = centerX;
|
|
lastCenterY = centerY;
|
|
if (activeZoom) {
|
|
return;
|
|
}
|
|
activeZoom = true;
|
|
|
|
// Our goal is to figure out how to scroll the window so that the
|
|
// location on the iframe corresponding to centerX/centerY maintains
|
|
// its position after zooming.
|
|
|
|
// centerX, centerY are in screen coordinates. Offset coordinates of
|
|
// the scrollContainer are screen (card) relative, but those of things
|
|
// inside the scrollContainer exist within that coordinate space and
|
|
// do not change as we scroll.
|
|
// console.log('----ZOOM from', scale, 'to', newScale);
|
|
// console.log('cx', centerX, 'cy', centerY,
|
|
// 'vl', viewport.offsetLeft,
|
|
// 'vt', viewport.offsetTop);
|
|
// console.log('sl', scrollContainer.offsetLeft,
|
|
// 'st', scrollContainer.offsetTop);
|
|
|
|
// Figure out how much of our iframe is scrolled off the screen.
|
|
var iframeScrolledTop = scrollContainer.scrollTop - extraHeight,
|
|
iframeScrolledLeft = scrollContainer.scrollLeft;
|
|
|
|
// and now convert those into iframe-relative coords
|
|
var ix = centerX + iframeScrolledLeft,
|
|
iy = centerY + iframeScrolledTop;
|
|
|
|
var scaleDelta = (newScale / scale);
|
|
|
|
var vertScrollDelta = Math.ceil(iy * scaleDelta),
|
|
horizScrollDelta = Math.ceil(ix * scaleDelta);
|
|
|
|
scale = newScale;
|
|
resizeFrame('zoom');
|
|
scrollContainer.scrollTop = vertScrollDelta + extraHeight - centerY;
|
|
scrollContainer.scrollLeft = horizScrollDelta - centerX;
|
|
|
|
// Right, so on a Flame device I'm noticing serious delays in getting all
|
|
// this painting and such done, so it seems like we really want to up this
|
|
// constant to let any async stuff happen and to give the system some time
|
|
// to recover and maybe run a GC. Because there is a very real chance of
|
|
// someone happilly zooming in-and-out over and over to cause us to hit a
|
|
// GC ceiling.
|
|
window.setTimeout(clearActiveZoom, iframeShimsOpts.zoomDelayMS);
|
|
};
|
|
var clearActiveZoom = function() {
|
|
activeZoom = false;
|
|
if (scale !== lastRequestedScale) {
|
|
window.requestAnimationFrame(function() {
|
|
// This is almost certainly going to cause a memory spike, so log it.
|
|
// ugh.
|
|
console.log('delayed zoomFrame timeout, probably causing a mem-spike');
|
|
zoomFrame(lastRequestedScale, lastCenterX, lastCenterY);
|
|
});
|
|
}
|
|
};
|
|
|
|
// See giant block comment and timer constants for a description of our
|
|
// polling logic and knobs.
|
|
var resizePollerTimeout = null;
|
|
// track how many times we've checked. We want to bound this for battery life
|
|
// purposes and also to avoid weird sad cases.
|
|
var resizePollCount = 0;
|
|
var pollResize = function() {
|
|
var opts = iframeShimsOpts;
|
|
var desiredScrollWidth = iframeBody.scrollWidth;
|
|
var desiredScrollHeight = iframeBody.scrollHeight;
|
|
var resized = false;
|
|
// if we need to grow, grow. (for stability reasons, we never want to
|
|
// shrink since it could lead to infinite oscillation)
|
|
if (desiredScrollWidth > scrollWidth ||
|
|
desiredScrollHeight > scrollHeight) {
|
|
resizeFrame('poll');
|
|
resized = true;
|
|
}
|
|
|
|
if (++resizePollCount < opts.resizeLimit) {
|
|
// we manually schedule ourselves for slack purposes
|
|
resizePollerTimeout = window.setTimeout(
|
|
pollResize,
|
|
resized ? opts.didResizePollIntervalMS : opts.noResizePollIntervalMS);
|
|
} else {
|
|
resizePollerTimeout = null;
|
|
}
|
|
};
|
|
resizePollerTimeout = window.setTimeout(
|
|
pollResize, iframeShimsOpts.initialResizePollIntervalMS);
|
|
|
|
var iframeShims = {
|
|
iframe: iframe,
|
|
// (This is invoked each time an image "load" event fires.)
|
|
resizeHandler: function() {
|
|
resizePollCount = 0;
|
|
// Reset the existing timeout because many emails with external images
|
|
// will have a LOT of external images so it could take a while for them
|
|
// all to load.
|
|
if (resizePollerTimeout) {
|
|
window.clearTimeout(resizePollerTimeout);
|
|
}
|
|
resizePollerTimeout = window.setTimeout(
|
|
pollResize, iframeShimsOpts.pictureDelayPollIntervalMS);
|
|
}
|
|
};
|
|
|
|
if (interactiveMode !== 'interactive') {
|
|
return iframeShims;
|
|
}
|
|
|
|
var detectorTarget = viewport;
|
|
var detector = new GestureDetector(detectorTarget);
|
|
// We don't need to ever stopDetecting since the closures that keep it
|
|
// alive are just the event listeners on the iframe.
|
|
detector.startDetecting();
|
|
// Using tap gesture event for URL link handling.
|
|
if (clickHandler) {
|
|
viewport.removeEventListener('click', clickHandler);
|
|
bindSanitizedClickHandler(viewport, clickHandler, null, iframe);
|
|
}
|
|
|
|
var title = document.getElementsByClassName('msg-reader-header')[0];
|
|
var header = document.getElementsByClassName('msg-envelope-bar')[0];
|
|
var extraHeight = title.clientHeight + header.clientHeight;
|
|
|
|
// -- Double-tap zoom idiom
|
|
detectorTarget.addEventListener('dbltap', function(e) {
|
|
var newScale = scale;
|
|
if (lastDoubleTapScale === lastRequestedScale) {
|
|
scaleMode = (scaleMode + 1) % 3;
|
|
switch (scaleMode) {
|
|
case 0:
|
|
newScale = baseScale;
|
|
break;
|
|
case 1:
|
|
newScale = 1;
|
|
break;
|
|
case 2:
|
|
newScale = 2;
|
|
break;
|
|
}
|
|
console.log('already in double-tap, deciding on new scale', newScale);
|
|
}
|
|
else {
|
|
// If already zoomed in, zoom out to starting scale
|
|
if (lastRequestedScale > 1) {
|
|
newScale = lastDoubleTapScale;
|
|
scaleMode = 0;
|
|
}
|
|
// Otherwise zoom in to 2x
|
|
else {
|
|
newScale = 2;
|
|
scaleMode = 2;
|
|
}
|
|
console.log('user was not in double-tap switching to double-tap with',
|
|
newScale);
|
|
}
|
|
lastDoubleTapScale = newScale;
|
|
try {
|
|
zoomFrame(newScale, e.detail.clientX, e.detail.clientY);
|
|
} catch (ex) {
|
|
console.error('zoom bug!', ex, '\n', ex.stack);
|
|
}
|
|
});
|
|
|
|
// -- quantized pinchy-zoomy idiom
|
|
// track whether we've already transformed this transform event cycle
|
|
var transformDone = false;
|
|
// reset when the transform has ended. (there is no transformbegin event)
|
|
detectorTarget.addEventListener('transformend', function(e) {
|
|
transformDone = false;
|
|
});
|
|
detectorTarget.addEventListener('transform', function(e) {
|
|
// if we already zoomed in/out this time, then just bail
|
|
if (transformDone) {
|
|
return;
|
|
}
|
|
|
|
var scaleFactor = e.detail.absolute.scale;
|
|
var newScale = lastRequestedScale;
|
|
// once it's clear this is a zoom-in, we can handle
|
|
if (scaleFactor > 1.15) {
|
|
transformDone = true;
|
|
// Zoom in if we can.
|
|
// (Note that if baseScale is 1, we will properly go direct to 1.5 from
|
|
// baseScale. Hooray!)
|
|
if (lastRequestedScale < 1) {
|
|
newScale = 1;
|
|
}
|
|
else if (lastRequestedScale < 1.5) {
|
|
newScale = 1.5;
|
|
}
|
|
else if (lastRequestedScale < 2) {
|
|
newScale = 2;
|
|
}
|
|
else {
|
|
return;
|
|
}
|
|
}
|
|
else if (scaleFactor < 0.9) {
|
|
transformDone = true;
|
|
if (lastRequestedScale > 1.5) {
|
|
newScale = 1.5;
|
|
}
|
|
else if (lastRequestedScale > 1) {
|
|
newScale = 1;
|
|
}
|
|
else if (lastRequestedScale > baseScale) {
|
|
newScale = baseScale;
|
|
}
|
|
else {
|
|
return;
|
|
}
|
|
}
|
|
else {
|
|
return;
|
|
}
|
|
zoomFrame(
|
|
newScale,
|
|
e.detail.midpoint.clientX, e.detail.midpoint.clientY);
|
|
});
|
|
|
|
|
|
return iframeShims;
|
|
}
|
|
|
|
function bindSanitizedClickHandler(target, clickHandler, topNode, iframe) {
|
|
var eventType, node;
|
|
// Variables that only valid for HTML type mail.
|
|
var root, title, header, attachmentsContainer, msgBodyContainer,
|
|
titleHeight, headerHeight, attachmentsHeight,
|
|
msgBodyMarginTop, msgBodyMarginLeft, attachmentsMarginTop,
|
|
iframeDoc, inputStyle, loadBar, loadBarHeight;
|
|
// Tap gesture event for HTML type mail and click event for plain text mail
|
|
if (iframe) {
|
|
root = document.getElementsByClassName('scrollregion-horizontal-too')[0];
|
|
title = document.getElementsByClassName('msg-reader-header')[0];
|
|
header = document.getElementsByClassName('msg-envelope-bar')[0];
|
|
attachmentsContainer =
|
|
document.getElementsByClassName('msg-attachments-container')[0];
|
|
loadBar = document.getElementsByClassName('msg-reader-load-infobar')[0];
|
|
msgBodyContainer = document.getElementsByClassName('msg-body-container')[0];
|
|
inputStyle = window.getComputedStyle(msgBodyContainer);
|
|
msgBodyMarginTop = parseInt(inputStyle.marginTop);
|
|
msgBodyMarginLeft = parseInt(inputStyle.marginLeft);
|
|
titleHeight = title.clientHeight;
|
|
headerHeight = header.clientHeight;
|
|
eventType = 'tap';
|
|
iframeDoc = iframe.contentDocument;
|
|
} else {
|
|
eventType = 'click';
|
|
}
|
|
target.addEventListener(
|
|
eventType,
|
|
function clicked(event) {
|
|
if (iframe) {
|
|
// Because the "show (external) images" loadBar could be opened or
|
|
// closed depending on what the user does relative to this click, get
|
|
// the client height at the time of click.
|
|
loadBarHeight = loadBar.clientHeight;
|
|
|
|
// Because the attachments are updating late,
|
|
// get the client height while clicking iframe.
|
|
attachmentsHeight = attachmentsContainer.clientHeight;
|
|
inputStyle = window.getComputedStyle(attachmentsContainer);
|
|
attachmentsMarginTop =
|
|
(attachmentsHeight) ? parseInt(inputStyle.marginTop) : 0;
|
|
var dx, dy;
|
|
var transform = iframe.style.transform || 'scale(1)';
|
|
var scale = transform.match(/(\d|\.)+/g)[0];
|
|
|
|
// When in rtl mode, scroll is relative to right side, but the
|
|
// document inside the iframe is ltr based, since it does not set a
|
|
// document-wide dir setting and instead the DOM content inside the
|
|
// message manages the dir itself.
|
|
if (document.dir === 'rtl') {
|
|
dx = event.detail.clientX - msgBodyMarginLeft +
|
|
// The scrollLeft is calculated from the right side, with right
|
|
// being zero and left being a negative value from the *right*
|
|
// edge of the element. So to get the x value from the left, need
|
|
// the difference of scrollWidth from scrollLeft (which is a
|
|
// negative value), and also subtracting out the width of the
|
|
// element to get the value relative to the *left* side of the
|
|
// element.
|
|
root.scrollWidth + root.scrollLeft - root.clientWidth;
|
|
} else {
|
|
dx = event.detail.clientX + root.scrollLeft - msgBodyMarginLeft;
|
|
}
|
|
|
|
dy = event.detail.clientY + root.scrollTop -
|
|
titleHeight - headerHeight - loadBarHeight -
|
|
attachmentsHeight - attachmentsMarginTop - msgBodyMarginTop;
|
|
|
|
node = iframeDoc.elementFromPoint(dx / scale, dy / scale);
|
|
|
|
// Uncomment to show a red square on where the code thinks the tap
|
|
// occurred in the iframe. Useful for debugging.
|
|
// var temp = iframeDoc.createElement('div');
|
|
// temp.style.position = 'absolute';
|
|
// temp.style.overflow = 'hidden';
|
|
// temp.style.top = ((dy / scale) - 5) + 'px';
|
|
// temp.style.left = ((dx / scale) - 5) + 'px';
|
|
// temp.style.width = '10px';
|
|
// temp.style.height = '10px';
|
|
// temp.style.backgroundColor = 'red';
|
|
// iframeDoc.body.appendChild(temp);
|
|
} else {
|
|
node = event.originalTarget;
|
|
}
|
|
while (node !== topNode) {
|
|
if (node.nodeName === 'A') {
|
|
if (node.hasAttribute('ext-href')) {
|
|
clickHandler(event, node, node.getAttribute('ext-href'),
|
|
node.textContent);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
createAndInsertIframeForContent: createAndInsertIframeForContent,
|
|
bindSanitizedClickHandler: bindSanitizedClickHandler,
|
|
iframeShimsOpts: iframeShimsOpts
|
|
};
|
|
|
|
});
|