mirror of
https://github.com/samuelclay/NewsBlur.git
synced 2025-08-20 13:25:48 +00:00
581 lines
17 KiB
JavaScript
581 lines
17 KiB
JavaScript
/**
|
|
* @preserve FastClick: polyfill to remove click delays on browsers with touch UIs.
|
|
*
|
|
* @version 0.4.6
|
|
* @codingstandard ftlabs-jsv2
|
|
* @copyright The Financial Times Limited [All Rights Reserved]
|
|
* @license MIT License (see LICENSE.txt)
|
|
*/
|
|
|
|
/*jslint browser:true, node:true*/
|
|
/*global define, Event, Node*/
|
|
|
|
|
|
/**
|
|
* Instantiate fast-clicking listeners on the specificed layer.
|
|
*
|
|
* @constructor
|
|
* @param {Element} layer The layer to listen on
|
|
*/
|
|
function FastClick(layer) {
|
|
'use strict';
|
|
var oldOnClick, self = this;
|
|
|
|
|
|
/**
|
|
* Whether a click is currently being tracked.
|
|
*
|
|
* @type boolean
|
|
*/
|
|
this.trackingClick = false;
|
|
|
|
|
|
/**
|
|
* Timestamp for when when click tracking started.
|
|
*
|
|
* @type number
|
|
*/
|
|
this.trackingClickStart = 0;
|
|
|
|
|
|
/**
|
|
* The element being tracked for a click.
|
|
*
|
|
* @type EventTarget
|
|
*/
|
|
this.targetElement = null;
|
|
|
|
|
|
/**
|
|
* X-coordinate of touch start event.
|
|
*
|
|
* @type number
|
|
*/
|
|
this.touchStartX = 0;
|
|
|
|
|
|
/**
|
|
* Y-coordinate of touch start event.
|
|
*
|
|
* @type number
|
|
*/
|
|
this.touchStartY = 0;
|
|
|
|
|
|
/**
|
|
* ID of the last touch, retrieved from Touch.identifier.
|
|
*
|
|
* @type number
|
|
*/
|
|
this.lastTouchIdentifier = 0;
|
|
|
|
|
|
/**
|
|
* The FastClick layer.
|
|
*
|
|
* @type Element
|
|
*/
|
|
this.layer = layer;
|
|
|
|
if (!layer || !layer.nodeType) {
|
|
throw new TypeError('Layer must be a document node');
|
|
}
|
|
|
|
/** @type function() */
|
|
this.onClick = function() { FastClick.prototype.onClick.apply(self, arguments); };
|
|
|
|
/** @type function() */
|
|
this.onTouchStart = function() { FastClick.prototype.onTouchStart.apply(self, arguments); };
|
|
|
|
/** @type function() */
|
|
this.onTouchMove = function() { FastClick.prototype.onTouchMove.apply(self, arguments); };
|
|
|
|
/** @type function() */
|
|
this.onTouchEnd = function() { FastClick.prototype.onTouchEnd.apply(self, arguments); };
|
|
|
|
/** @type function() */
|
|
this.onTouchCancel = function() { FastClick.prototype.onTouchCancel.apply(self, arguments); };
|
|
|
|
// Devices that don't support touch don't need FastClick
|
|
if (typeof window.ontouchstart === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
// Set up event handlers as required
|
|
layer.addEventListener('click', this.onClick, true);
|
|
layer.addEventListener('touchstart', this.onTouchStart, false);
|
|
layer.addEventListener('touchmove', this.onTouchMove, false);
|
|
layer.addEventListener('touchend', this.onTouchEnd, false);
|
|
layer.addEventListener('touchcancel', this.onTouchCancel, false);
|
|
|
|
// Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
|
|
// which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick
|
|
// layer when they are cancelled.
|
|
if (!Event.prototype.stopImmediatePropagation) {
|
|
layer.removeEventListener = function(type, callback, capture) {
|
|
var rmv = Node.prototype.removeEventListener;
|
|
if (type === 'click') {
|
|
rmv.call(layer, type, callback.hijacked || callback, capture);
|
|
} else {
|
|
rmv.call(layer, type, callback, capture);
|
|
}
|
|
};
|
|
|
|
layer.addEventListener = function(type, callback, capture) {
|
|
var adv = Node.prototype.addEventListener;
|
|
if (type === 'click') {
|
|
adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
|
|
if (!event.propagationStopped) {
|
|
callback(event);
|
|
}
|
|
}), capture);
|
|
} else {
|
|
adv.call(layer, type, callback, capture);
|
|
}
|
|
};
|
|
}
|
|
|
|
// If a handler is already declared in the element's onclick attribute, it will be fired before
|
|
// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
|
|
// adding it as listener.
|
|
if (typeof layer.onclick === 'function') {
|
|
|
|
// Android browser on at least 3.2 requires a new reference to the function in layer.onclick
|
|
// - the old one won't work if passed to addEventListener directly.
|
|
oldOnClick = layer.onclick;
|
|
layer.addEventListener('click', function(event) {
|
|
oldOnClick(event);
|
|
}, false);
|
|
layer.onclick = null;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Android requires an exception for labels.
|
|
*
|
|
* @type boolean
|
|
*/
|
|
FastClick.prototype.deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0;
|
|
|
|
|
|
/**
|
|
* iOS requires an exception for alert confirm dialogs.
|
|
*
|
|
* @type boolean
|
|
*/
|
|
FastClick.prototype.deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
|
|
|
|
|
|
/**
|
|
* iOS 4 requires an exception for select elements.
|
|
*
|
|
* @type boolean
|
|
*/
|
|
FastClick.prototype.deviceIsIOS4 = FastClick.prototype.deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent);
|
|
|
|
|
|
/**
|
|
* Determine whether a given element requires a native click.
|
|
*
|
|
* @param {EventTarget|Element} target Target DOM element
|
|
* @returns {boolean} Returns true if the element needs a native click
|
|
*/
|
|
FastClick.prototype.needsClick = function(target) {
|
|
'use strict';
|
|
switch (target.nodeName.toLowerCase()) {
|
|
case 'label':
|
|
case 'video':
|
|
return true;
|
|
default:
|
|
return (/\bneedsclick\b/).test(target.className);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Determine whether a given element requires a call to focus to simulate click into element.
|
|
*
|
|
* @param {EventTarget|Element} target Target DOM element
|
|
* @returns {boolean} Returns true if the element requires a call to focus to simulate native click.
|
|
*/
|
|
FastClick.prototype.needsFocus = function(target) {
|
|
'use strict';
|
|
switch (target.nodeName.toLowerCase()) {
|
|
case 'textarea':
|
|
case 'select':
|
|
return true;
|
|
case 'input':
|
|
switch (target.type) {
|
|
case 'button':
|
|
case 'checkbox':
|
|
case 'file':
|
|
case 'image':
|
|
case 'radio':
|
|
case 'submit':
|
|
return false;
|
|
}
|
|
return true;
|
|
default:
|
|
return (/\bneedsfocus\b/).test(target.className);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Send a click event to the specified element.
|
|
*
|
|
* @param {EventTarget|Element} targetElement
|
|
* @param {Event} event
|
|
*/
|
|
FastClick.prototype.sendClick = function(targetElement, event) {
|
|
'use strict';
|
|
var clickEvent, touch;
|
|
|
|
// On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24)
|
|
if (document.activeElement && document.activeElement !== targetElement) {
|
|
document.activeElement.blur();
|
|
}
|
|
|
|
touch = event.changedTouches[0];
|
|
|
|
// Synthesise a click event, with an extra attribute so it can be tracked
|
|
clickEvent = document.createEvent('MouseEvents');
|
|
clickEvent.initMouseEvent('click', true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
|
|
clickEvent.forwardedTouchEvent = true;
|
|
targetElement.dispatchEvent(clickEvent);
|
|
};
|
|
|
|
|
|
/**
|
|
* On touch start, record the position and scroll offset.
|
|
*
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
FastClick.prototype.onTouchStart = function(event) {
|
|
'use strict';
|
|
var touch = event.targetTouches[0];
|
|
|
|
this.trackingClick = true;
|
|
this.trackingClickStart = event.timeStamp;
|
|
this.targetElement = event.target;
|
|
this.theTarget = $(this.targetElement).closest('a').get(0);
|
|
|
|
this.theTarget.className += ' pressed';
|
|
|
|
this.touchStartX = touch.pageX;
|
|
this.touchStartY = touch.pageY;
|
|
this.startClickTime = new Date;
|
|
|
|
// Prevent phantom clicks on fast double-tap (issue #36)
|
|
if ((event.timeStamp - this.lastClickTime) < 200) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
|
|
/**
|
|
* Based on a touchmove event object, check whether the touch has moved past a boundary since it started.
|
|
*
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
FastClick.prototype.touchHasMoved = function(event) {
|
|
'use strict';
|
|
var touch = event.targetTouches[0];
|
|
|
|
if (Math.abs(touch.pageX - this.touchStartX) > 10 || Math.abs(touch.pageY - this.touchStartY) > 10) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
|
|
/**
|
|
* Update the last position.
|
|
*
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
FastClick.prototype.onTouchMove = function(event) {
|
|
'use strict';
|
|
if (!this.trackingClick) {
|
|
return true;
|
|
}
|
|
|
|
// If the touch has moved, cancel the click tracking
|
|
if (this.targetElement !== event.target || this.touchHasMoved(event)) {
|
|
this.trackingClick = false;
|
|
this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, '');
|
|
this.targetElement = null;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
|
|
/**
|
|
* Attempt to find the labelled control for the given label element.
|
|
*
|
|
* @param {EventTarget|HTMLLabelElement} labelElement
|
|
* @returns {Element|null}
|
|
*/
|
|
FastClick.prototype.findControl = function(labelElement) {
|
|
'use strict';
|
|
|
|
// Fast path for newer browsers supporting the HTML5 control attribute
|
|
if (labelElement.control !== undefined) {
|
|
return labelElement.control;
|
|
}
|
|
|
|
// All browsers under test that support touch events also support the HTML5 htmlFor attribute
|
|
if (labelElement.htmlFor) {
|
|
return document.getElementById(labelElement.htmlFor);
|
|
}
|
|
|
|
// If no for attribute exists, attempt to retrieve the first labellable descendant element
|
|
// the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label
|
|
return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea');
|
|
};
|
|
|
|
|
|
/**
|
|
* On touch end, determine whether to send a click event at once.
|
|
*
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
FastClick.prototype.onTouchEnd = function(event) {
|
|
'use strict';
|
|
var forElement, trackingClickStart, targetElement = this.targetElement, touch = event.changedTouches[0];
|
|
|
|
if (!this.trackingClick) {
|
|
return true;
|
|
}
|
|
|
|
// Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23):
|
|
// when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched
|
|
// with the same identifier as the touch event that previously triggered the click that triggered the alert.
|
|
if (this.deviceIsIOS) {
|
|
if (touch.identifier === this.lastTouchIdentifier) {
|
|
event.preventDefault();
|
|
return false;
|
|
}
|
|
|
|
this.lastTouchIdentifier = touch.identifier;
|
|
}
|
|
|
|
// Prevent phantom clicks on fast double-tap (issue #36)
|
|
if ((event.timeStamp - this.lastClickTime) < 200) {
|
|
this.cancelNextClick = true;
|
|
return true;
|
|
}
|
|
|
|
if ((new Date - this.startClickTime) > 115) {
|
|
return false;
|
|
}
|
|
this.lastClickTime = event.timeStamp;
|
|
|
|
trackingClickStart = this.trackingClickStart;
|
|
this.trackingClick = false;
|
|
this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, '');
|
|
this.trackingClickStart = 0;
|
|
|
|
if (targetElement.nodeName.toLowerCase() === 'label') {
|
|
forElement = this.findControl(targetElement);
|
|
if (forElement) {
|
|
targetElement.focus();
|
|
if (this.deviceIsAndroid) {
|
|
return false;
|
|
}
|
|
|
|
if (!this.needsClick(forElement)) {
|
|
event.preventDefault();
|
|
this.sendClick(forElement, event);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
} else if (this.needsFocus(targetElement)) {
|
|
|
|
// If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through.
|
|
if ((event.timeStamp - trackingClickStart) > 100) {
|
|
this.targetElement = null;
|
|
return true;
|
|
}
|
|
|
|
targetElement.focus();
|
|
|
|
// Select elements need the event to go through on iOS 4, otherwise the selector menu won't open.
|
|
if (!this.deviceIsIOS4 || targetElement.tagName.toLowerCase() !== 'select') {
|
|
this.targetElement = null;
|
|
event.preventDefault();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Prevent the actual click from going though - unless the target node is marked as requiring
|
|
// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted.
|
|
if (!this.needsClick(targetElement)) {
|
|
event.preventDefault();
|
|
this.sendClick(targetElement, event);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
|
|
/**
|
|
* On touch cancel, stop tracking the click.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
FastClick.prototype.onTouchCancel = function() {
|
|
'use strict';
|
|
this.theTarget.className = this.theTarget.className.replace(/ ?pressed/gi, '');
|
|
this.trackingClick = false;
|
|
this.targetElement = null;
|
|
};
|
|
|
|
|
|
/**
|
|
* On actual clicks, determine whether this is a touch-generated click, a click action occurring
|
|
* naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or
|
|
* an actual click which should be permitted.
|
|
*
|
|
* @param {Event} event
|
|
* @returns {boolean}
|
|
*/
|
|
FastClick.prototype.onClick = function(event) {
|
|
'use strict';
|
|
|
|
var oldTargetElement;
|
|
|
|
// If a target element was never set (because a touch event was never fired) allow the click
|
|
if (!this.targetElement) {
|
|
return true;
|
|
}
|
|
|
|
if (event.forwardedTouchEvent) {
|
|
return true;
|
|
}
|
|
|
|
oldTargetElement = this.targetElement;
|
|
this.targetElement = null;
|
|
|
|
// It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early.
|
|
if (this.trackingClick) {
|
|
this.trackingClick = false;
|
|
return true;
|
|
}
|
|
|
|
// Programmatically generated events targeting a specific element should be permitted
|
|
if (!event.cancelable) {
|
|
return true;
|
|
}
|
|
|
|
// Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target.
|
|
if (event.target.type === 'submit' && event.detail === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Derive and check the target element to see whether the click needs to be permitted;
|
|
// unless explicitly enabled, prevent non-touch click events from triggering actions,
|
|
// to prevent ghost/doubleclicks.
|
|
if (!this.needsClick(oldTargetElement) || this.cancelNextClick) {
|
|
this.cancelNextClick = false;
|
|
|
|
// Prevent any user-added listeners declared on FastClick element from being fired.
|
|
if (event.stopImmediatePropagation) {
|
|
event.stopImmediatePropagation();
|
|
} else {
|
|
|
|
// Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
|
|
event.propagationStopped = true;
|
|
}
|
|
|
|
// Cancel the event
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
|
|
return false;
|
|
}
|
|
|
|
// If clicks are permitted, return true for the action to go through.
|
|
return true;
|
|
};
|
|
|
|
|
|
/**
|
|
* Remove all FastClick's event listeners.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
FastClick.prototype.destroy = function() {
|
|
'use strict';
|
|
var layer = this.layer;
|
|
|
|
layer.removeEventListener('click', this.onClick, true);
|
|
layer.removeEventListener('touchstart', this.onTouchStart, false);
|
|
layer.removeEventListener('touchmove', this.onTouchMove, false);
|
|
layer.removeEventListener('touchend', this.onTouchEnd, false);
|
|
layer.removeEventListener('touchcancel', this.onTouchCancel, false);
|
|
};
|
|
|
|
|
|
if (typeof define !== 'undefined' && define.amd) {
|
|
|
|
// AMD. Register as an anonymous module.
|
|
define(function() {
|
|
'use strict';
|
|
return FastClick;
|
|
});
|
|
}
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = function(layer) {
|
|
'use strict';
|
|
return new FastClick(layer);
|
|
};
|
|
|
|
module.exports.FastClick = FastClick;
|
|
}
|
|
|
|
|
|
function attachFastClick(options) {
|
|
options = options || {};
|
|
var avatars = document.getElementsByClassName("NB-show-profile");
|
|
Array.prototype.slice.call(avatars, 0).forEach(function(avatar) {
|
|
new FastClick(avatar, options);
|
|
});
|
|
var tags = document.getElementsByClassName("NB-story-tag");
|
|
Array.prototype.slice.call(tags, 0).forEach(function(tag) {
|
|
new FastClick(tag, options);
|
|
});
|
|
var authors = document.getElementsByClassName("NB-story-author");
|
|
Array.prototype.slice.call(authors, 0).forEach(function(author) {
|
|
new FastClick(author, options);
|
|
});
|
|
var publishers = document.getElementsByClassName("NB-story-publisher");
|
|
Array.prototype.slice.call(publishers, 0).forEach(function(publisher) {
|
|
new FastClick(publisher, options);
|
|
});
|
|
var titles = document.getElementsByClassName("NB-story-title");
|
|
Array.prototype.slice.call(titles, 0).forEach(function(title) {
|
|
new FastClick(title, options);
|
|
});
|
|
|
|
var author = document.getElementById("NB-story-author");
|
|
if (author) {
|
|
new FastClick(author, options);
|
|
}
|
|
}
|
|
|
|
Zepto(function($) {
|
|
attachFastClick();
|
|
});
|