554 lines
18 KiB
JavaScript
Executable File
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();
|
|
}
|
|
|