Created
June 27, 2017 14:31
-
-
Save yellow1912/0a1f4d31aec3889b5464aa293d6cfd4e to your computer and use it in GitHub Desktop.
ExitIntent detection
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Naive ExitIntent detector | |
* | |
* @author Vu Nguyen | |
* @link https://nilead.com | |
* @license Do Whatever The Fuck You Want | |
*/ | |
(function () { | |
"use strict"; | |
/** Detect free variable `global` from Node.js. */ | |
var freeGlobal = typeof global == 'object' && global && global.Object === Object && global; | |
/** Detect free variable `self`. */ | |
var freeSelf = typeof self == 'object' && self && self.Object === Object && self; | |
/** Used as a reference to the global object. */ | |
var root = freeGlobal || freeSelf || Function('return this')(); | |
var _html = document.documentElement, currentLocation = normalizeUrl(location.href); | |
function extend(out) { | |
out = out || {}; | |
for (var i = 1; i < arguments.length; i ++) { | |
if (! arguments[i]) { | |
continue; | |
} | |
for (var key in arguments[i]) { | |
if (arguments[i].hasOwnProperty(key)) { | |
out[key] = arguments[i][key]; | |
} | |
} | |
} | |
return out; | |
} | |
function normalizeUrl(url) { | |
if (url.indexOf('//') === 0) { | |
url = location.protocol + url; | |
} | |
return url.toLowerCase().replace(/([a-z])?:\/\//, '$1').split('/')[0]; | |
} | |
function isExternal(url) { | |
return ( ( url.indexOf(':') > - 1 || url.indexOf('//') > - 1 ) && currentLocation !== normalizeUrl(url) ); | |
} | |
// Helper functions | |
var supportsPassiveOption = false; | |
try { | |
var opts = Object.defineProperty({}, 'passive', { | |
get: function () { | |
supportsPassiveOption = true; | |
}.bind(this) | |
}); | |
window.addEventListener('test', null, opts); | |
window.removeEventListener('test', null, opts); | |
} catch (e) { | |
} | |
function addEventListener(elem, event, fn, options, useCapture) { | |
if (! supportsPassiveOption && 'object' == typeof options) { | |
options = options.hasOwnProperty('capture') ? options.capture : false; | |
} | |
if ('undefined' === typeof options) { | |
options = {}; | |
} | |
if (elem.addEventListener) { | |
elem.addEventListener(event, fn, options, useCapture || false); | |
} else { | |
elem.attachEvent("on" + event, fn); // older versions of IE | |
} | |
} | |
function removeEventListener(elem, event, fn, options, useCapture) { | |
if (! supportsPassiveOption && 'object' == typeof options) { | |
options = options.hasOwnProperty('capture') ? options.capture : false; | |
} | |
if ('undefined' === typeof options) { | |
options = false; | |
} | |
if (elem.removeEventListener) { | |
elem.removeEventListener(event, fn, options, useCapture || false); | |
} else { | |
elem.detachEvent("on" + event, fn); // older versions of IE | |
} | |
} | |
function createCookie(name, value, days) { | |
var expires = ""; | |
if (days) { | |
var date = new Date(); | |
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); | |
expires = "; expires=" + date.toUTCString(); | |
} | |
document.cookie = name + "=" + value + expires + "; path=/"; | |
} | |
function readCookie(name) { | |
var nameEQ = name + "="; | |
var ca = document.cookie.split(';'); | |
for (var i = 0; i < ca.length; i ++) { | |
var c = ca[i]; | |
while (c.charAt(0) == ' ') c = c.substring(1, c.length); | |
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); | |
} | |
return null; | |
} | |
function eraseCookie(name) { | |
createCookie(name, "", - 1); | |
} | |
/** | |
* A pretty naive class to detects and dispatch exit intent | |
* | |
* Method 1: if the user mouse out of the current windows within the set period of time, we assume he/she wants to leave | |
* | |
* Method 2: if the user click on a link different from the current domain, we assume he/she wants to leave | |
* | |
* Method 3: if the browser triggers onbeforeunload event | |
* | |
* Unlike other plugins, this one will not care about the number of time it should trigger, in fact we believe that it should trigger as many time as necessary | |
* It's up to the users of the plugin to catch the exit intent and do what you want to do with it | |
*/ | |
class ExitIntent { | |
constructor(options) { | |
options = options || {}; | |
this._delayTimer = null; | |
this._unloadTimer = null; | |
this._unloadable = false; | |
this._active = true; | |
this.options = extend({ | |
initDelay: 2000, // we can delay a bit before initializing, this allows us to trigger to early for users who don't want to see it | |
ignoreMultipleInstances: true, // ignore exit intent if the user is opening on multiple tabs and or windows | |
detectMouse: true, // should we use mouse in/out detection? | |
detectLink: true, // should we use link click detection? | |
detectUnload: true, // should we use unload detection? (risky, disabled by default) | |
// options for mouse detection | |
exitDelay: 2000, // delay (in millisecond) before we fire the event. Long delay will allow us to ignore accidental mouse movement but will increase risk of missing real events | |
sensitivityX: 50, // distance away from the right edge to count as exit | |
sensitivityY: 50, // distance away from the top edge to count as exit | |
// options for link detection | |
excludeSelector: null, // exclude certain links for detection, use querySelector :not | |
ignoreOpenInNewTab: true, // if the link is opened in new tab we don't count it as exit intend | |
// options for unload detection | |
unloadMessage: 'Hey, before you leave!', // the no please do not leave message | |
unloadSensitivity: 500, // since we capture key press to guess user exit intention, we use this ms time to set how long we wait after each key press to register the unload as exit intention | |
exit: function () { | |
} | |
}, options); | |
// to avoid spamming people, we may want add a small delay before we initialize our detector | |
setTimeout(() => { | |
this.init(); | |
}, this.options.initDelay); | |
if (this.options.ignoreMultipleInstances) { | |
// we do not want to trigger the exit intention if multiple step is open | |
let instances = parseInt(readCookie('nl-exit-instances') || 0); | |
createCookie('nl-exit-instances', instances + 1); | |
addEventListener(window, 'unload', () => { | |
let instances = readCookie('nl-exit-instances') || 1; | |
createCookie('nl-exit-instances', instances - 1); | |
}); | |
} | |
} | |
init() { | |
// do we detect using mouse movements? | |
if (this.options.detectMouse) { | |
addEventListener(_html, 'mouseenter', this.handleMouseEnter.bind(this), { | |
capture: false, passive: true | |
}); | |
addEventListener(_html, 'mouseleave', this.handleMouseLeave.bind(this), { | |
capture: false, passive: true | |
}); | |
} | |
// do we detect using the link interactions? | |
if (this.options.detectLink) { | |
let links; | |
if (this.options.excludeSelector) { | |
links = _html.querySelectorAll('a:not(' + this.options.excludeSelector + ')'); | |
} else { | |
links = _html.getElementsByTagName('a'); | |
} | |
for (let link of links) { | |
let href = link.getAttribute('href'); | |
if (this.options.ignoreOpenInNewTab && '_blank' == link.getAttribute('target')) { | |
continue; | |
} | |
// we only care about http, https and // links | |
// we also do not consider internal links as exit intent | |
if (href && (href.indexOf('http') === 0 || href.indexOf('//') === 0) && isExternal(href)) { | |
addEventListener(link, 'click', this.handleLinkClick.bind(this), { | |
passive: false, capture: false, once: true | |
}); | |
} | |
} | |
} | |
// do we detect using the unload interactions? | |
if (this.options.detectUnload) { | |
addEventListener(window, 'keydown', this.handleKeydown.bind(this), {capture: false, passive: true}); | |
addEventListener(window, 'keyup', this.handleKeyup.bind(this), {capture: false, passive: true}); | |
addEventListener(window, 'beforeunload', this.handleUnload.bind(this), { | |
capture: false, | |
passive: true | |
}); | |
} | |
} | |
/** | |
* What we can do here is that we can take note that the mouse is outside of the viewport | |
* If the mouse leaves our viewport, then we can assume that the user | |
* 1. goes to another tab: unload event won't be triggered | |
* 2. click close: unload event will be triggered | |
* 3. click back/forward: unload event will also be triggered @#$#@%@#%@#$ | |
* 4. click refresh: unload event will also be triggered | |
* | |
* Due to the security model of browsers, we actually have no reliable way to know how the users leave our web page | |
* If we trigger the exit event here, we actually will capture both the refresh and back/forward as well | |
* | |
* @param e | |
*/ | |
handleMouseLeave(e) { | |
// ignore if not active | |
if (! this._active) { | |
return; | |
} | |
// Get the current viewport width. | |
var vpWidth = Math.max(_html.clientWidth, window.innerWidth || 0); | |
// If the current mouse X position is within Xpx of the right edge | |
// of the viewport, return. | |
if (e.clientX >= (vpWidth - this.options.sensitivityX)) { | |
return; | |
} | |
// If the current mouse Y position is not within Ypx of the top | |
// edge of the viewport, return. | |
if (e.clientY >= this.options.sensitivityY) { | |
return; | |
} | |
// Reliable, works on mouse exiting window and | |
// user switching active program | |
var from = e.relatedTarget || e.toElement; | |
if (! from) { | |
this._unloadable = true; | |
// this._delayTimer = setTimeout(() => { | |
// this.triggerExit('mouse', e, this); | |
// }, this.options.exitDelay); | |
} | |
} | |
handleMouseEnter(e) { | |
// ignore if not active | |
if (! this._active) { | |
return; | |
} | |
this._unloadable = false; | |
// cancel our exit intent since this is just an accident :D | |
// if (this._delayTimer) { | |
// clearTimeout(this._delayTimer); | |
// } | |
} | |
/** | |
* If we click on an external link, we certainly assume exit intent | |
* | |
* @param e | |
*/ | |
handleLinkClick(e) { | |
// should we ignore the open in new tab actions? | |
// TODO: certain OS such as IOS has different behavior such as the double tap to open link in new tab | |
if (this.options.ignoreOpenInNewTab) { | |
if (e.ctrlKey || e.shiftKey || e.metaKey || // metaKey is for apple | |
(e.button && e.button == 1) // middle click, >IE9 + everyone else | |
) { | |
return; | |
} | |
} | |
if (this.shouldTriggerExit()) { | |
e.preventDefault(); | |
this.trigger('exit', 'link', e, this); | |
} | |
} | |
handleKeydown(e) { | |
// ignore if not active | |
if (! this._active) { | |
return; | |
} | |
if (null == this._unloadTimer) { | |
// if the shift, ctrl, or mac key was pressed, | |
// we assume that if the unload event happens within a given amount of time | |
// then the unload will be either refresh, or close | |
if (e.shiftKey || e.metaKey || e.ctrlKey) { | |
this._unloadable = true; | |
this._unloadTimer = setTimeout(() => { | |
this._unloadable = false; | |
this._unloadTimer = null; | |
}, this.options.unloadSensitivity); | |
return; | |
} | |
// this is the alt key | |
let vKey = e.keyCode ? e.keyCode : e.which ? e.which : e.charCode; | |
if (18 == vKey) { | |
this._unloadable = true; | |
this._unloadTimer = setTimeout(() => { | |
this._unloadable = false; | |
this._unloadTimer = null; | |
}, this.options.unloadSensitivity); | |
} | |
} | |
} | |
handleKeyup(e) { | |
// ignore if not active | |
if (! this._active) { | |
return; | |
} | |
if (this._unloadTimer) { | |
if (e.shiftKey || e.metaKey || e.ctrlKey) { | |
clearTimeout(this._unloadTimer); | |
this._unloadTimer = null; | |
} | |
let vKey = e.keyCode ? e.keyCode : e.which ? e.which : e.charCode; | |
if (18 == vKey) { | |
clearTimeout(this._unloadTimer); | |
this._unloadTimer = null; | |
} | |
} | |
} | |
handleUnload(e) { | |
if (this._unloadable && this.shouldTriggerExit()) { | |
this.trigger('exit', 'unload', e, this); | |
e.returnValue = this.options.unloadMessage; | |
return this.options.unloadMessage; | |
} | |
} | |
pause() { | |
this._active = false; | |
} | |
resume() { | |
this._active = true; | |
} | |
shouldTriggerExit() { | |
if (! this._active) { | |
return false; | |
} | |
if (this.options.ignoreMultipleInstances) { | |
let instances = parseInt(readCookie('nl-exit-instances') || 1); | |
if (instances > 1) { | |
return false; | |
} | |
} | |
return true; | |
} | |
destroy() { | |
// do we detect using mouse movements? | |
if (this.options.detectMouse) { | |
removeEventListener(_html, 'mouseenter', this.handleMouseEnter.bind(this), { | |
capture: false, passive: true | |
}); | |
removeEventListener(_html, 'mouseleave', this.handleMouseLeave.bind(this), { | |
capture: false, passive: true | |
}); | |
} | |
// do we detect using the link interactions? | |
if (this.options.detectLink) { | |
let links; | |
if (this.options.excludeSelector) { | |
links = _html.querySelectorAll('a:not(' + this.options.excludeSelector + ')'); | |
} else { | |
links = _html.getElementsByTagName('a'); | |
} | |
for (let link of links) { | |
let href = link.getAttribute('href'); | |
if (href && href.indexOf('mailto') !== 0 && isExternal(href)) { | |
removeEventListener(link, 'click', this.handleLinkClick.bind(this), { | |
passive: false, capture: false, once: true | |
}); | |
} | |
} | |
} | |
// do we detect using the unload interactions? | |
if (this.options.detectUnload) { | |
removeEventListener(window, 'keydown', this.handleKeydown.bind(this), { | |
capture: false, passive: true | |
}); | |
removeEventListener(window, 'keyup', this.handleKeyup.bind(this), {capture: false, passive: true}); | |
removeEventListener(window, 'beforeunload', this.handleUnload.bind(this), { | |
capture: false, passive: true | |
}); | |
} | |
} | |
trigger(hook, ...args) { | |
if ('function' === typeof this.options[hook]) { | |
this.options[hook].apply(this, args); | |
} | |
} | |
} | |
root.NuExitIntent = ExitIntent; | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment