Sequenzia/lib/assets/javascripts/moe-legacy/touchscreen-web-app-helpers.js
2013-10-26 18:06:58 -05:00

554 lines
18 KiB
JavaScript
Executable File

/*
* This file implements several helpers for fixing up full-page web apps on touchscreen
* browsers:
*
* AndroidDetectWindowSize
* EmulateDoubleClick
* ResponsiveSingleClick
* PreventDragScrolling
*
* Most of these are annoying hacks to work around the fact that WebKit on browsers was
* designed with displaying scrolling webpages in mind, apparently without consideration
* for full-screen applications: pages that should fill the screen at all times. Most
* of the browser mobile hacks no longer make sense: separate display viewports, touch
* dragging, double-click zooming and their associated side-effects.
*/
/*
* AndroidDetectWindowSize
*
* Implementing a full-page web app for Android is hard, because if you set the page to
* "width: 100%; height: 100%;" it'll eat a big chunk of the screen with the address bar
* which can't be scrolled off in that configuration. We have to play games to figure out
* the real size of the window, and set the body size to it explicitly. This handler does
* the following:
*
* - capture resize events
* - cancel the resize event; we'll fire it again when we're done
* - enable a large padding div, to ensure that we can scroll the window downward
* - window.scrollTo(0, 99999999) to scroll the address bar off screen, which increases the window
* size to the maximum. We use a big value here, because Android has a broken scrollTo, which
* animates to the specified position. If we say (0, 1), then it'll take a while to scroll
* there; by giving it a huge value, it'll scroll past the scrollbar in one frame.
* - wait a little while. We need to wait for one frame of scrollTo's animation, but we don't
* know how long that'll be, so we need to poll with a timer periodically, checking
* document.body.scrollTop.
* - set the body to the size of the window
* - hide the padding div
* - synthesize a new resize event to continue other event handlers that we originally cancelled
*
* resize will always be fired at least once as a result of constructing this class.
*
* This is only used on Android.
*/
function AndroidDetectWindowSize()
{
$("sizing-body").setStyle({overflow: "hidden"});
/* This is shown to make sure we can scroll the address bar off. It goes outside
* of #sizing-body, so it's not clipped. By not changing #sizing-body itself, we
* avoid reflowing the entire document more than once, when we finish. */
this.padding = document.createElement("DIV");
this.padding.setStyle({width: "1px", height: "5000px"});
this.padding.style.visibility = "hidden";
this.padding.hide();
document.documentElement.appendChild(this.padding);
this.window_size = [0, 0];
this.finish = this.finish.bind(this);
this.event_onresize = this.event_onresize.bindAsEventListener(this);
this.finish_timer = null;
this.last_window_orientation = window.orientation;
window.addEventListener("resize", this.event_onresize, true);
this.active = false;
/* Kick off a detection cycle. On Android 2.1, we can't do this immediately after onload; for
* some reason this triggers some very strange browser bug where the screen will jitter up and
* down, as if our scrollTo is competing against the browser trying to scroll somewhere. For
* older browsers, delay before starting. This is no longer needed on Android 2.2. */
var delay_seconds = 0;
var m = navigator.userAgent.match(/Android (\d+\.\d+)/);
if(m && parseFloat(m[1]) < 2.2)
{
debug("Delaying bootstrapping due to Android version " + m[1]);
delay_seconds = 1;
}
/* When this detection cycle completes, a resize event will be fired so listeners can
* act on the detected window size. */
this.begin.bind(this).delay(delay_seconds);
}
/* Return true if Android resize handling is needed. */
AndroidDetectWindowSize.required = function()
{
// XXX: be more specific
return navigator.userAgent.indexOf("Android") != -1;
}
/* After we set the window size, dispatch a resize event so other listeners will notice
* it. */
AndroidDetectWindowSize.prototype.dispatch_resize_event = function()
{
debug("dispatch final resize event");
var e = document.createEvent("Event");
e.initEvent("resize", true, true);
document.documentElement.dispatchEvent(e);
}
AndroidDetectWindowSize.prototype.begin = function()
{
if(this.active)
return;
var initial_window_size = this.current_window_size();
if(this.window_size && initial_window_size[0] == this.window_size[0] && initial_window_size[1] == this.window_size[1])
{
debug("skipped window size detection");
return;
}
debug("begin window size detection, " + initial_window_size[0] + "x" + initial_window_size[1] + " at start (scroll pos " + document.documentElement.scrollHeight + ")");
this.active = true;
this.padding.show();
/* If we set a sizing-body the last time, remove it before running again. */
$("sizing-body").setStyle({width: "0px", height: "0px"});
window.scrollTo(0, 99999999);
this.finish_timer = window.setTimeout(this.finish, 0);
}
AndroidDetectWindowSize.prototype.end = function()
{
if(!this.active)
return;
this.active = false;
if(this.begin_timer != null)
window.clearTimeout(this.begin_timer);
this.begin_timer = null;
if(this.finish_timer != null)
window.clearTimeout(this.finish_timer);
this.finish_timer = null;
this.padding.hide();
}
AndroidDetectWindowSize.prototype.current_window_size = function()
{
var size = [window.innerWidth, window.innerHeight];
// We need to fudge the height up a pixel, or in many cases we'll end up with a white line
// at the bottom of the screen (or the top in 2.3). This seems to be sub-pixel rounding
// error.
++size[1];
return size;
}
AndroidDetectWindowSize.prototype.finish = function()
{
if(!this.active)
return;
debug("window size detection: finish(), at " + document.body.scrollTop);
/* scrollTo is supposed to be synchronous. Android's animates. Worse, the time it'll
* update the animation is nondeterministic; it might happen as soon as we return from
* calling scrollTo, or it might take a while. Check whether we've scrolled down; if
* we're still at the top, keep waiting. */
if(document.body.scrollTop == 0)
{
console.log("Waiting for scroll...");
this.finish_timer = window.setTimeout(this.finish, 10);
return;
}
/* The scroll may still be trying to run. */
window.scrollTo(document.body.scrollLeft, document.body.scrollTop);
this.end();
this.window_size = this.current_window_size();
debug("new window size: " + this.window_size[0] + "x" + this.window_size[1]);
$("sizing-body").setStyle({width: this.window_size[0] + "px", height: (this.window_size[1]) + "px"});
this.dispatch_resize_event();
}
AndroidDetectWindowSize.prototype.event_onresize = function(e)
{
if(this.last_window_orientation != window.orientation)
{
e.stop();
this.last_window_orientation = window.orientation;
if(this.active)
{
/* The orientation changed while we were in the middle of detecting the resolution.
* Start over. */
debug("Orientation changed while already detecting window size; restarting");
this.end();
}
else
{
debug("Resize received with an orientation change; beginning");
}
this.begin();
return;
}
if(this.active)
{
/* Suppress resize events while we're active, since many of them will fire.
* Once we finish, we'll fire a single one. */
debug("stopping resize event while we're active");
e.stop();
return;
}
}
/*
* Work around a bug on many touchscreen browsers: even when the page isn't
* zoomable, dblclick is never fired. We have to emulate it.
*
* This isn't an exact emulation of the event behavior:
*
* - It triggers from touchstart rather than mousedown. The second mousedown
* of a double click isn't being fired reliably in Android's WebKit.
*
* - preventDefault on the triggering event should prevent a dblclick, but
* we can't find out if it's been called; there's nothing like Firefox's
* getPreventDefault. We could mostly emulate this by overriding
* Event.preventDefault to set a flag that we can read.
*
* - The conditions for a double click won't match the ones of the platform.
*
* This is needed on Android and iPhone's WebKit.
*
* Note that this triggers a minor bug on Android: after firing a dblclick event,
* we no longer receive mousemove events until the touch is released, which means
* PreventDragScrolling can't cancel dragging.
*/
function EmulateDoubleClick()
{
this.touchstart_event = this.touchstart_event.bindAsEventListener(this);
this.touchend_event = this.touchend_event.bindAsEventListener(this);
this.last_click = null;
window.addEventListener("touchstart", this.touchstart_event, false);
window.addEventListener("touchend", this.touchend_event, false);
}
EmulateDoubleClick.prototype.touchstart_event = function(event)
{
var this_touch = event.changedTouches[0];
var last_click = this.last_click;
/* Don't store event.changedTouches or any of its contents. Some browsers modify these
* objects in-place between events instead of properly returning unique events. */
var this_click = {
timeStamp: event.timeStamp,
target: event.target,
identifier: this_touch.identifier,
position: [this_touch.screenX, this_touch.screenY],
clientPosition: [this_touch.clientX, this_touch.clientY]
}
this.last_click = this_click;
if(last_click == null)
return;
/* If the first tap was never released then this is a multitouch double-tap.
* Clear the original tap and don't fire anything. */
if(event.touches.length > 1)
return;
/* Check that not too much time has passed. */
var time_since_previous = event.timeStamp - last_click.timeStamp;
if(time_since_previous > 500)
return;
/* Check that the clicks aren't too far apart. */
var distance = Math.pow(this_touch.screenX - last_click.position[0], 2) + Math.pow(this_touch.screenY - last_click.position[1], 2);
if(distance > 500)
return;
if(event.target != last_click.target)
return;
/* Synthesize a dblclick event. Use the coordinates of the first click as the location
* and not the second click, since if the position matters the user's first click of
* a double-click is probably more precise than the second. */
var e = document.createEvent("MouseEvent");
e.initMouseEvent("dblclick", true, true, window,
2,
last_click.position[0], last_click.position[1],
last_click.clientPosition[0], last_click.clientPosition[1],
false, false,
false, false,
0, null);
this.last_click = null;
event.target.dispatchEvent(e);
}
EmulateDoubleClick.prototype.touchend_event = function(event)
{
if(this.last_click == null)
return;
var last_click_identifier = this.last_click.identifier;
if(last_click_identifier == null)
return;
var last_click_position = this.last_click.position;
var this_click = event.changedTouches[0];
if(this_click.identifier == last_click_identifier)
{
/* If the touch moved too far when it was removed, don't fire a doubleclick; for
* example, two quick swipe gestures aren't a double-click. */
var distance = Math.pow(this_click.screenX - last_click_position[0], 2) + Math.pow(this_click.screenY - last_click_position[1], 2);
if(distance > 500)
{
this.last_click = null;
return;
}
}
}
/*
* Mobile WebKit has serious problems with the click event: it delays them for the
* entire double-click timeout, and if a double-click happens it doesn't deliver the
* click at all. This makes clicks unresponsive, and it has this behavior even
* when the page can't be zoomed, which means nothing happens at all.
*
* Generate click events from touchend events to bypass this mess.
*/
ResponsiveSingleClick = function()
{
this.click_event = this.click_event.bindAsEventListener(this);
this.touchstart_event = this.touchstart_event.bindAsEventListener(this);
this.touchend_event = this.touchend_event.bindAsEventListener(this);
this.last_touch = null;
window.addEventListener("touchstart", this.touchstart_event, false);
window.addEventListener("touchend", this.touchend_event, false);
/* This is a capturing listener, so we can intercept clicks before they're
* delivered to anyone. */
window.addEventListener("click", this.click_event, true);
}
ResponsiveSingleClick.prototype.touchstart_event = function(event)
{
/* If we get a touch while we already have a touch, it's multitouch, which is never
* a click, so cancel the click. */
if(this.last_touch != null)
{
debug("Cancelling click (multitouch)");
this.last_touch = null;
return;
}
/* Watch out: in older versions of WebKit, the event.touches array and the items inside
* it are actually modified in-place when the user drags. That means that we can't just
* save the entire array for comparing in touchend. */
var touch = event.changedTouches[0];
this.last_touch = [touch.screenX, touch.screenY];
}
ResponsiveSingleClick.prototype.touchend_event = function(event)
{
var last_touch = this.last_touch;
if(last_touch == null)
return;
this.last_touch = null;
var touch = event.changedTouches[0];
var this_touch = [touch.screenX, touch.screenY];
/* Don't trigger a click if the point has moved too far. */
var distance = distance_squared(this_touch[0], this_touch[1], last_touch[0], last_touch[1]);
if(distance > 50)
return;
var e = document.createEvent("MouseEvent");
e.initMouseEvent("click", true, true, window,
1,
touch.screenX, touch.screenY,
touch.clientX, touch.clientY,
false, false,
false, false,
0, /* touch clicks are always button 0 - maybe not for multitouch */
null);
e.synthesized_click = true;
/* If we dispatch the click immediately, EmulateDoubleClick won't receive a
* touchstart for the next click. Defer dispatching it until we return. */
(function() { event.target.dispatchEvent(e); }).defer();
}
/* Capture and cancel all clicks except the ones we generate. */
ResponsiveSingleClick.prototype.click_event = function(event)
{
if(!event.synthesized_click)
event.stop();
}
/* Stop all touchmove events on the document, to prevent dragging the window around. */
PreventDragScrolling = function()
{
Element.observe(document, "touchmove", function(event) {
event.preventDefault();
});
}
/*
* Save the URL hash to local DOM storage when it changes. When called, restores the
* previously saved hash.
*
* This is used on the iPhone only, and only when operating in web app mode (window.standalone).
* The iPhone doesn't update the URL hash saved in the web app shortcut, nor does it
* remember the current URL when using make-believe multitasking, which means every time
* you switch out and back in you end up back to wherever you were when you first created
* the web app shortcut. Saving the URL hash allows switching out and back in without losing
* your place.
*
* This should only be used in environments where it's been tested and makes sense. If used
* in a browser, or in a web app environment that properly tracks the URL hash, this will
* just interfere with normal operation.
*/
var MaintainUrlHash = function()
{
/* This requires DOM storage. */
if(LocalStorageDisabled())
return;
/* When any part of the URL hash changes, save it. */
var update_stored_hash = function(changed_hash_keys, old_hash, new_hash)
{
var hash = localStorage.current_hash = UrlHash.get_raw_hash();
}
UrlHash.observe(null, update_stored_hash);
/* Restore the previous hash, if any. */
var hash = localStorage.getItem("current_hash");
if(hash)
UrlHash.set_raw_hash(hash);
}
/*
* In some versions of the browser, iPhones don't send resize events after an
* orientation change, so we need to fire it ourself. Try not to do this if not
* needed, so we don't fire spurious events.
*
* This is never needed in web app mode.
*
* Needed on user-agents:
* iPhone OS 4_0_2 ... AppleWebKit/532.9 ... Version/4.0.5
* iPhone OS 4_1 ... AppleWebKit/532.9 ... Version/4.0.5
*
* Not needed on:
* (iPad, OS 3.2)
* CPU OS 3_2 ... AppleWebKit/531.1.10 ... Version/4.0.4
* iPhone OS 4_2 ... AppleWebKit/533.17.9 ... Version/5.0.2
*
* This seems to be specific to Version/4.0.5.
*/
var SendMissingResizeEvents = function()
{
if(window.navigator.standalone)
return;
if(navigator.userAgent.indexOf("Version/4.0.5") == -1)
return;
var last_seen_orientation = window.orientation;
window.addEventListener("orientationchange", function(e) {
if(last_seen_orientation == window.orientation)
return;
last_seen_orientation = window.orientation;
debug("dispatch fake resize event");
var e = document.createEvent("Event");
e.initEvent("resize", true, true);
document.documentElement.dispatchEvent(e);
}, true);
}
var InitializeFullScreenBrowserHandlers = function()
{
/* These handlers deal with heavily browser-specific issues. Only install them
* on browsers that have been tested to need them. */
if(navigator.userAgent.indexOf("Android") != -1 && navigator.userAgent.indexOf("WebKit") != -1)
{
new ResponsiveSingleClick();
new EmulateDoubleClick();
}
else if((navigator.userAgent.indexOf("iPhone") != -1 || navigator.userAgent.indexOf("iPad") != -1 || navigator.userAgent.indexOf("iPod") != -1)
&& navigator.userAgent.indexOf("WebKit") != -1)
{
new ResponsiveSingleClick();
new EmulateDoubleClick();
/* In web app mode only: */
if(window.navigator.standalone)
MaintainUrlHash();
SendMissingResizeEvents();
}
PreventDragScrolling();
}
SwipeHandler = function(element)
{
this.element = element;
this.dragger = new DragElement(element, { ondrag: this.ondrag.bind(this), onstartdrag: this.startdrag.bind(this) });
}
SwipeHandler.prototype.startdrag = function()
{
this.swiped_horizontal = false;
this.swiped_vertical = false;
}
SwipeHandler.prototype.ondrag = function(e)
{
if(!this.swiped_horizontal)
{
// XXX: need a guessed DPI
if(Math.abs(e.aX) > 100)
{
this.element.fire("swipe:horizontal", {right: e.aX > 0});
this.swiped_horizontal = true;
}
}
if(!this.swiped_vertical)
{
if(Math.abs(e.aY) > 100)
{
this.element.fire("swipe:vertical", {down: e.aY > 0});
this.swiped_vertical = true;
}
}
}
SwipeHandler.prototype.destroy = function()
{
this.dragger.destroy();
}