From 7e3870e7340cac95a432fa36506415ae59dc7ac5 Mon Sep 17 00:00:00 2001 From: Dan Wilkerson Date: Mon, 11 Sep 2017 12:39:46 -0400 Subject: [PATCH] V2.0.0 (#6) * Reworked to decouple scroll observing from GA tracking, added support for nested scroll areas. --- Gruntfile.js | 57 +-- LICENSE.MD | 2 +- karma.conf.js | 20 + luna-scroll-tracking.json | 410 +++++++++------- lunametrics-scroll-tracking.gtm.js | 519 -------------------- lunametrics-scroll-tracking.gtm.min.js | 29 -- package.json | 14 +- readme.md | 300 ++---------- scroll-tracker.js | 654 +++++++++++++++++++++++++ scroll-tracker.min.js | 8 + src/lunametrics-scroll-tracking.gtm.js | 512 ------------------- src/scroll-tracker.js | 647 ++++++++++++++++++++++++ test/scrollTracker.spec.js | 337 +++++++++++++ 13 files changed, 1963 insertions(+), 1546 deletions(-) create mode 100644 karma.conf.js delete mode 100644 lunametrics-scroll-tracking.gtm.js delete mode 100644 lunametrics-scroll-tracking.gtm.min.js create mode 100644 scroll-tracker.js create mode 100644 scroll-tracker.min.js delete mode 100644 src/lunametrics-scroll-tracking.gtm.js create mode 100644 src/scroll-tracker.js create mode 100644 test/scrollTracker.spec.js diff --git a/Gruntfile.js b/Gruntfile.js index 8b5aa18..876e05c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,3 +1,6 @@ +/* + * @TODO add task for updating container + */ var fs = require('fs'); var jsBeautify = require('js-beautify').js_beautify; @@ -15,7 +18,6 @@ module.exports = function(grunt) { grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), jshint: { - // files: ['./src/*.js'], ignore_warning: { options: { '-W030': true, @@ -26,26 +28,18 @@ module.exports = function(grunt) { }, uglify: { options: { - footer: footer + footer: '\r\n' + footer }, build: { - src: './src/lunametrics-scroll-tracking.gtm.js', - dest: './lunametrics-scroll-tracking.gtm.min.js' + src: './src/scroll-tracker.js', + dest: './scroll-tracker.min.js' } }, - fixConfig: { + appendFooter: { options: { build: { - src: './lunametrics-scroll-tracking.gtm.min.js', - dest: './lunametrics-scroll-tracking.gtm.min.js' - } - } - }, - prependFooter: { - options: { - build: { - src: './src/lunametrics-scroll-tracking.gtm.js', - dest: './lunametrics-scroll-tracking.gtm.js' + src: './src/scroll-tracker.js', + dest: './scroll-tracker.js' }, footer: footer } @@ -53,8 +47,8 @@ module.exports = function(grunt) { updateContainer: { options: { build: { - src: './lunametrics-scroll-tracking.gtm.js', - dest: './luna-scroll-tracking.json' + src: './scroll-tracker.js', + dest: './luna-gtm-scroll-tracker.json' } } } @@ -63,32 +57,19 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-jshint'); - grunt.registerTask('fixConfig', ['Reformat config argument for readability'], function() { + grunt.registerTask('appendFooter', ['append credits to footer'], function() { var options = this.options(); var data = fs.readFileSync(options.build.src, 'utf-8'); - var minifiedConfig = data.split('/*')[0].match(/\(.*?\)/g).pop(); - var config = minifiedConfig.replace(/!0/g, 'true').replace(/!1/g, 'false'); - var beautifiedConfig = jsBeautify(config); - var data = data.replace(minifiedConfig, '\n' + beautifiedConfig); - fs.writeFileSync(options.build.dest, data); - console.log('Appended properly formatted config to end of minified script'); - - }); - - grunt.registerTask('prependFooter', ['Prepend credits to footer'], function() { - - var options = this.options(); - var data = fs.readFileSync(options.build.src, 'utf-8'); fs.writeFileSync(options.build.dest, data + options.footer); - console.log('Prepended footer to unminifed script'); + console.log('appended footer to unminifed script'); }); - grunt.registerTask('updateContainer', ['Updating container import file'], function() { + /*grunt.registerTask('updateContainer', ['Updating container import file'], function() { var options = this.options(); - var oldContainer = require(options.build.dest); + var oldContainer = require(options.build.dest); var newScript = fs.readFileSync(options.build.src, 'utf-8'); var oldTag, oldParameter, @@ -114,15 +95,15 @@ module.exports = function(grunt) { } } - + oldContainer.containerVersion.tag[oldTag].parameter[oldParameter].value = ''; - + fs.writeFileSync(options.build.dest, jsBeautify(JSON.stringify(oldContainer))); - }); + });*/ - grunt.registerTask('default', ['jshint', 'prependFooter', 'uglify', 'fixConfig', 'updateContainer']); + grunt.registerTask('default', ['jshint', 'appendFooter', 'uglify', /*'updateContainer'*/]); }; diff --git a/LICENSE.MD b/LICENSE.MD index c58f7f3..e6d994f 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016 LunaMetrics, LLC +Copyright (c) 2017 LunaMetrics, LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..5b8dd08 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,20 @@ +module.exports = function(config) { + 'use strict'; + + config.set({ + + basePath: './', + + frameworks: ["jasmine"], + + files: [ + 'src/scroll-tracker.js', + 'test/**/*.spec.js' + ], + + autoWatch: true, + + browsers: ['Chrome'] + + }); +}; diff --git a/luna-scroll-tracking.json b/luna-scroll-tracking.json index e28e5fe..b90586f 100644 --- a/luna-scroll-tracking.json +++ b/luna-scroll-tracking.json @@ -1,186 +1,246 @@ { - "exportFormatVersion": 1.3, - "exportTime": "2015-10-16 20:33:36", + "exportFormatVersion": 2, + "exportTime": "2017-09-11 16:33:16", "containerVersion": { - "accountId": "28896164", - "containerId": "1607357", + "path": "accounts/263956808/containers/2187445/versions/0", + "accountId": "263956808", + "containerId": "2187445", "containerVersionId": "0", - "deleted": false, "container": { - "accountId": "28896164", - "containerId": "1607357", + "path": "accounts/263956808/containers/2187445", + "accountId": "263956808", + "containerId": "2187445", "name": "Scroll Tracking", - "publicId": "GTM-T7FDZT", - "timeZoneCountryId": "US", - "timeZoneId": "America/Los_Angeles", - "notes": "", - "usageContext": ["WEB"], - "fingerprint": "1445027600600", - "enabledBuiltInVariable": ["DEBUG_MODE"] + "publicId": "GTM-NRXSRH", + "usageContext": [ + "WEB" + ], + "fingerprint": "1504900536606", + "tagManagerUrl": "https://tagmanager.google.com/#/container/accounts/263956808/containers/2187445/workspaces?apiLink=container" }, - "tag": [{ - "accountId": "28896164", - "containerId": "1607357", - "tagId": "19", - "name": "CU - Scroll Tracking - LunaMetrics Plugin", - "type": "html", - "liveOnly": false, - "parameter": [{ - "type": "BOOLEAN", - "key": "supportDocumentWrite", - "value": "false" - }, { - "type": "TEMPLATE", - "key": "html", - "value": "" - }], - "fingerprint": "0", - "firingTriggerId": ["2147479553"], - "parentFolderId": "11", - "tagFiringOption": "ONCE_PER_EVENT" - }, { - "accountId": "28896164", - "containerId": "1607357", - "tagId": "20", - "name": "GA - Event - Scroll Tracking", - "type": "ua", - "liveOnly": false, - "parameter": [{ - "type": "BOOLEAN", - "key": "enableEcommerce", - "value": "false" - }, { - "type": "BOOLEAN", - "key": "setTrackerName", - "value": "false" - }, { - "type": "LIST", - "key": "fieldsToSet", - "list": [{ - "type": "MAP", - "map": [{ + "tag": [ + { + "accountId": "263956808", + "containerId": "2187445", + "tagId": "8", + "name": "CU - Scroll Tracking - LunaMetrics Plugin", + "type": "html", + "parameter": [ + { "type": "TEMPLATE", - "key": "fieldName", - "value": "cookieDomain" - }, { + "key": "html", + "value": "" + }, + { + "type": "BOOLEAN", + "key": "supportDocumentWrite", + "value": "false" + } + ], + "fingerprint": "1505146352508", + "firingTriggerId": [ + "2147479553" + ], + "parentFolderId": "6", + "tagFiringOption": "ONCE_PER_EVENT" + }, + { + "accountId": "263956808", + "containerId": "2187445", + "tagId": "9", + "name": "GA - Event - Scroll Tracking", + "type": "ua", + "parameter": [ + { + "type": "BOOLEAN", + "key": "nonInteraction", + "value": "true" + }, + { "type": "TEMPLATE", - "key": "value", - "value": "auto" - }] - }] - }, { - "type": "BOOLEAN", - "key": "doubleClick", - "value": "false" - }, { - "type": "TEMPLATE", - "key": "useDebugVersion", - "value": "{{Debug Mode}}" - }, { - "type": "TEMPLATE", - "key": "trackingId", - "value": "{{YOUR_GA_TRACKING_ID}}" - }, { - "type": "TEMPLATE", - "key": "trackType", - "value": "TRACK_EVENT" - }, { - "type": "BOOLEAN", - "key": "nonInteraction", - "value": "true" - }, { - "type": "BOOLEAN", - "key": "enableLinkId", - "value": "false" - }, { - "type": "TEMPLATE", - "key": "eventLabel", - "value": "{{DLV - attributes.label}}" - }, { - "type": "TEMPLATE", - "key": "eventAction", - "value": "{{DLV - attributes.distance}}" - }, { - "type": "TEMPLATE", - "key": "eventCategory", - "value": "Scroll Tracking" - }], - "fingerprint": "0", - "firingTriggerId": ["10"], - "parentFolderId": "11", - "tagFiringOption": "ONCE_PER_EVENT" - }], + "key": "useDebugVersion", + "value": "{{Debug Mode}}" + }, + { + "type": "TEMPLATE", + "key": "eventCategory", + "value": "Scroll Tracking" + }, + { + "type": "TEMPLATE", + "key": "trackType", + "value": "TRACK_EVENT" + }, + { + "type": "TEMPLATE", + "key": "eventAction", + "value": "{{DLV - attributes.distance}}" + }, + { + "type": "TEMPLATE", + "key": "eventLabel", + "value": "{{DLV - attributes.label}}" + }, + { + "type": "BOOLEAN", + "key": "overrideGaSettings", + "value": "true" + }, + { + "type": "BOOLEAN", + "key": "setTrackerName", + "value": "false" + }, + { + "type": "BOOLEAN", + "key": "doubleClick", + "value": "false" + }, + { + "type": "LIST", + "key": "fieldsToSet", + "list": [ + { + "type": "MAP", + "map": [ + { + "type": "TEMPLATE", + "key": "fieldName", + "value": "cookieDomain" + }, + { + "type": "TEMPLATE", + "key": "value", + "value": "auto" + } + ] + } + ] + }, + { + "type": "BOOLEAN", + "key": "enableLinkId", + "value": "false" + }, + { + "type": "BOOLEAN", + "key": "enableEcommerce", + "value": "false" + }, + { + "type": "TEMPLATE", + "key": "trackingId", + "value": "{{YOUR_GA_TRACKING_ID}}" + } + ], + "fingerprint": "1504901136050", + "firingTriggerId": [ + "7" + ], + "parentFolderId": "6", + "tagFiringOption": "ONCE_PER_EVENT" + } + ], + "trigger": [ + { + "accountId": "263956808", + "containerId": "2187445", + "triggerId": "7", + "name": "Event - Scroll Tracking", + "type": "CUSTOM_EVENT", + "customEventFilter": [ + { + "type": "EQUALS", + "parameter": [ + { + "type": "TEMPLATE", + "key": "arg0", + "value": "{{_event}}" + }, + { + "type": "TEMPLATE", + "key": "arg1", + "value": "scrollTracking" + } + ] + } + ], + "fingerprint": "1462238385247", + "parentFolderId": "6" + } + ], + "variable": [ + { + "accountId": "263956808", + "containerId": "2187445", + "variableId": "5", + "name": "DLV - attributes.distance", + "type": "v", + "parameter": [ + { + "type": "BOOLEAN", + "key": "setDefaultValue", + "value": "false" + }, + { + "type": "INTEGER", + "key": "dataLayerVersion", + "value": "2" + }, + { + "type": "TEMPLATE", + "key": "name", + "value": "attributes.distance" + } + ], + "fingerprint": "1494007112284", + "parentFolderId": "6" + }, + { + "accountId": "263956808", + "containerId": "2187445", + "variableId": "6", + "name": "DLV - attributes.label", + "type": "v", + "parameter": [ + { + "type": "BOOLEAN", + "key": "setDefaultValue", + "value": "false" + }, + { + "type": "INTEGER", + "key": "dataLayerVersion", + "value": "2" + }, + { + "type": "TEMPLATE", + "key": "name", + "value": "attributes.label" + } + ], + "fingerprint": "1494007112284", + "parentFolderId": "6" + } + ], + "folder": [ + { + "accountId": "263956808", + "containerId": "2187445", + "folderId": "6", + "name": "LunaMetrics Scroll Tracking Plugin", + "fingerprint": "1462238385246" + } + ], + "builtInVariable": [ + { + "accountId": "263956808", + "containerId": "2187445", + "type": "DEBUG_MODE", + "name": "Debug Mode" + } + ], "fingerprint": "0", - "trigger": [{ - "accountId": "28896164", - "containerId": "1607357", - "triggerId": "10", - "name": "Event - Scroll Tracking", - "type": "CUSTOM_EVENT", - "customEventFilter": [{ - "type": "EQUALS", - "parameter": [{ - "type": "TEMPLATE", - "key": "arg0", - "value": "{{_event}}" - }, { - "type": "TEMPLATE", - "key": "arg1", - "value": "scrollTracking" - }] - }], - "fingerprint": "1445027600600", - "parentFolderId": "11" - }], - "variable": [{ - "accountId": "28896164", - "containerId": "1607357", - "variableId": "19", - "name": "DLV - attributes.distance", - "type": "v", - "parameter": [{ - "type": "BOOLEAN", - "key": "setDefaultValue", - "value": "false" - }, { - "type": "TEMPLATE", - "key": "name", - "value": "attributes.distance" - }, { - "type": "INTEGER", - "key": "dataLayerVersion", - "value": "2" - }], - "fingerprint": "0", - "parentFolderId": "11" - }, { - "accountId": "28896164", - "containerId": "1607357", - "variableId": "20", - "name": "DLV - attributes.label", - "type": "v", - "parameter": [{ - "type": "BOOLEAN", - "key": "setDefaultValue", - "value": "false" - }, { - "type": "TEMPLATE", - "key": "name", - "value": "attributes.label" - }, { - "type": "INTEGER", - "key": "dataLayerVersion", - "value": "2" - }], - "fingerprint": "0", - "parentFolderId": "11" - }], - "folder": [{ - "accountId": "28896164", - "containerId": "1607357", - "folderId": "11", - "name": "LunaMetrics Scroll Tracking Plugin", - "fingerprint": "1445027599934" - }] + "tagManagerUrl": "https://tagmanager.google.com/#/versions/accounts/263956808/containers/2187445/versions/0?apiLink=version" } } \ No newline at end of file diff --git a/lunametrics-scroll-tracking.gtm.js b/lunametrics-scroll-tracking.gtm.js deleted file mode 100644 index 695fb0e..0000000 --- a/lunametrics-scroll-tracking.gtm.js +++ /dev/null @@ -1,519 +0,0 @@ -;(function(document, window, config) { - - 'use strict'; - - // Global cache we'll use to ensure no double-tracking occurs - var MarksAlreadyTracked = {}; - - // Backwards compatible with old every setting, which was single value - if (config.distances.percentages && config.distances.percentages.every) { - - if (!isArray_(config.distances.percentages.every)) { - - config.distances.percentages.every = [config.distances.percentages.every]; - - } - - } - - // Backwards compatible with old every setting, which was single value - if (config.distances.pixels && config.distances.pixels.every) { - - if (!isArray_(config.distances.pixels.every)) { - - config.distances.pixels.every = [config.distances.pixels.every]; - - } - - } - - // Get a hold of any relevant elements, if specified in config - var elementDistances = (function(selectors) { - - // If no selectors specified, short circuit here - if (!selectors) return; - - // Create a cache to store positions of elements temporarily - var cache = {}; - var counter = 0; - - // Fetch latest positions - _update(); - - // Return a function that can be called to get a map of element positions - return function () { - - // Clone here to prevent inheritance from getMarks step - var shallowClone = {}; - var key; - - counter++; - - // If temp cache counter is greater than 10, re-poll elements - if (counter > 10) { - - _update(); - counter = 0; - - } - - for (key in cache) { - - shallowClone[key] = cache[key]; - - } - - return shallowClone; - - }; - - function _update() { - - var selector, - markName, - els, - el, - y, - i; - // Clear current cache - cache = {}; - - if (selectors.each) { - - for (i = 0; i < selectors.each.length; i++) { - - selector = selectors.each[i]; - - if (!MarksAlreadyTracked[selector]) { - - el = document.querySelector(selector); - - if (el) cache[selector] = getNodeDistanceFromTop(el); - - } - - } - - } - - if (selectors.every) { - - for (i = 0; i < selectors.every.length; i++) { - - selector = selectors.every[i]; - els = document.querySelectorAll(selector); - - // If the last item in the selected group has been tracked, we skip it - if (els.length && !MarksAlreadyTracked[selector + ':' + (els.length - 1)]) { - - for (y = 0; y < els.length; y++) { - - markName = selector + ':' + y; - - // We also check at the individual element level - if (!MarksAlreadyTracked[markName]) { - - el = els[y]; - cache[markName] = getNodeDistanceFromTop(el); - - } - - } - - } - - } - - } - - } - - })(config.distances.elements); - - // If our document is ready to go, fire straight away - if(document.readyState !== 'loading') { - - init(); - - } else { - - // On IE8 this fires on window.load, all other browsers will fire when DOM ready - document.addEventListener ? - addEvent(document, 'DOMContentLoaded', init) : - addEvent(window, 'load', init); - - } - - function init() { - - // Browser dependencies, script fails silently - if (!document.querySelector || !document.body.getBoundingClientRect) { - - return false; - - } - - // Set our dataLayer name for later - config.dataLayerName = config.dataLayerName || 'dataLayer'; - - // Initialize our distances, for later - config.distances = config.distances || {}; - - checkDepth(); - addEvent(window, 'scroll', throttle(checkDepth, 500)); - - } - - function getMarks(_docHeight, _offset) { - - var marks = elementDistances() || {}; - var percents = []; - var pixels = []; - var everyPercent, - everyPixel, - i; - - if(config.distances.percentages) { - - if(config.distances.percentages.each) { - - percents = percents.concat(config.distances.percentages.each); - - } - - if(config.distances.percentages.every) { - - for (i = 0; i < config.distances.percentages.every.length; i++) { - - everyPercent = every_(config.distances.percentages.every[i], 100); - percents = pixels.concat(everyPercent); - - } - - } - - } - - if(config.distances.pixels) { - - if(config.distances.pixels.each) { - - pixels = pixels.concat(config.distances.pixels.each); - - } - - if(config.distances.pixels.every) { - - for (i = 0; i < config.distances.pixels.every.length; i++) { - - everyPixel = every_(config.distances.pixels.every[i], _docHeight); - pixels = pixels.concat(everyPixel); - - } - - } - - } - - marks = addMarks_(marks, percents, '%', _docHeight, _offset); - marks = addMarks_(marks, pixels, 'px', _docHeight, _offset); - - return marks; - - } - - function addMarks_(marks, points, symbol, _docHeight, _offset) { - - var i; - - for(i = 0; i < points.length; i++) { - - var _point = parseInt(points[i], 10); - var height = symbol !== '%' ? _point + _offset : _docHeight * (_point / 100) + _offset; - var mark = _point + symbol; - - if(height <= _docHeight + _offset) { - - marks[mark] = height; - - } - - } - - return marks; - - } - - function every_(n, total) { - - var _n = parseInt(n, 10); - var _num = total / _n; - var arr = []; - var i; - - for(i = 1; i < _num + 1; i++) { - - arr.push(i * _n); - - } - - return arr; - - } - - function checkDepth() { - - var _bottom = parseScrollBorder(config.bottom); - var _top = parseScrollBorder(config.top); - var height = docHeight(_bottom, _top); - var marks = getMarks(height, (_top || 0)); - var _curr = currentPosition(); - var target, - key; - - for(key in marks) { - - target = marks[key]; - - // If we've scrolled past the mark, we haven't tracked it yet, and it's in range, track the mark - if( - _curr > target && - !MarksAlreadyTracked[key] && - target < (_bottom || Infinity) && - target > (_top || 0) - ) { - - MarksAlreadyTracked[key] = true; - fireAnalyticsEvent(key); - - } - - } - - } - - function fireAnalyticsEvent(distance) { - - var _ga = window.GoogleAnalyticsObject; - - if(typeof window[config.dataLayerName] !== 'undefined' && !config.forceSyntax) { - - window[config.dataLayerName].push( { - 'event': 'scrollTracking', - 'attributes': { - 'distance': distance, - 'label': config.label - } - }); - - } else if (typeof window[_ga] === 'function' && - typeof window[_ga].getAll === 'function' && - config.forceSyntax !== 2) - { - - window[_ga]('send', 'event', config.category, distance, config.label, {'nonInteraction': 1}); - - } else if(typeof window._gaq !== 'undefined' && config.forceSyntax !== 1) { - - window._gaq.push(['_trackEvent', config.category, distance, config.label, 0, true]); - - } - - } - - function parseScrollBorder(border) { - - if(typeof border === 'number' || parseInt(border, 10)) { - - return parseInt(border, 10); - - } - - try { - - // If we have an element or a query selector, poll getBoundingClientRect - var el = border.nodeType === 1 ? border : document.querySelector(border); - - return getNodeDistanceFromTop(el); - - } catch (e) { - - return void(0); - - } - - } - - // Adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY - function currentPosition() { - - var supportPageOffset = window.pageXOffset !== undefined; - var isCSS1Compat = ((document.compatMode || "") === "CSS1Compat"); - - var currScrollTop = supportPageOffset ? - window.pageYOffset : - isCSS1Compat ? - document.documentElement.scrollTop : - document.body.scrollTop; - - return parseInt(currScrollTop, 10) + parseInt(viewportHeight(), 10); - - } - - function viewportHeight() { - - var elem = (document.compatMode === "CSS1Compat") ? - document.documentElement : - document.body; - - return elem.clientHeight; - - } - - function docHeight(_bottom, _top) { - - var body = document.body; - var html = document.documentElement; - - var height = Math.max(body.scrollHeight, body.offsetHeight, - html.clientHeight, html.scrollHeight, html.offsetHeight); - - - if(_top) { - - height = height - _top; - - } - - if(_bottom) { - - height = _bottom - (_top || 0); - - } - - return height - 5; - - } - - - /* - * Throttle function borrowed from: - * Underscore.js 1.5.2 - * http://underscorejs.org - * (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - * Underscore may be freely distributed under the MIT license. - */ - function throttle(func, wait) { - var context, args, result; - var timeout = null; - var previous = 0; - var later = function() { - previous = new Date; - timeout = null; - result = func.apply(context, args); - }; - return function() { - var now = new Date; - if (!previous) previous = now; - var remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0) { - clearTimeout(timeout); - timeout = null; - previous = now; - result = func.apply(context, args); - } else if (!timeout) { - timeout = setTimeout(later, remaining); - } - return result; - }; - } - - // Cross-browser compliant event listener - function addEvent(el, evt, fn) { - - if (el.addEventListener) { - - el.addEventListener(evt, fn); - - } else if (el.attachEvent) { - - el.attachEvent('on' + evt, function(evt) { - - // Call the event to ensure uniform 'this' handling, pass it event - fn.call(el, evt); - - }); - - } else if (typeof el['on' + evt] === 'undefined' || el['on' + evt] === null) { - - el['on' + evt] = function(evt) { - - // Call the event to ensure uniform 'this' handling, pass it event - fn.call(el, evt); - - }; - - } - - } - - // Helper for fetching top of element - function getNodeDistanceFromTop(node) { - - var nodeTop = node.getBoundingClientRect().top; - // https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX - var docTop = (window.pageYOffset !== undefined) ? - window.pageYOffset : - (document.documentElement || document.body.parentNode || document.body).scrollTop; - - return nodeTop + docTop; - - } - - // Helper to check if something is an Array - function isArray_(thing) { - - return thing instanceof Array; - - } - -})(document, window, { - // Use 2 to force Classic Analytics hits and 1 for Universal hits - 'forceSyntax': false, - // False if you just use the default dataLayer variable, otherwise enter it here - 'dataLayerName': false, - 'distances': { - // Configure percentages of page you'd like to see if users scroll past - 'percentages': { - 'each': [10,90], - 'every': [25] - }, - // Configure for pixel measurements of page you'd like to see if users scroll past - 'pixels': { - 'each': [], - 'every': [] - }, - // Configure elements you'd like to see users scroll past (using CSS Selectors) - 'elements': { - 'each': [], - 'every': [] - } - }, - // Accepts a number, DOM element, or query selector to determine the top of the scrolling area - 'top': null, - // Accepts a number, DOM element, or query selector to determine the bottom of the scrolling area - 'bottom': null, - // Text for Event Category - 'category': 'Scroll Tracking', - // Text for Event Label - 'label': document.location.pathname -}); -/* - * v1.1.2 - * Created by the Google Analytics consultants at http://www.lunametrics.com/ - * Written by @notdanwilkerson - * Documentation: https://github.com/lunametrics/gascroll/ - * Licensed under the MIT License - */ \ No newline at end of file diff --git a/lunametrics-scroll-tracking.gtm.min.js b/lunametrics-scroll-tracking.gtm.min.js deleted file mode 100644 index 256cce1..0000000 --- a/lunametrics-scroll-tracking.gtm.min.js +++ /dev/null @@ -1,29 +0,0 @@ -!function(a,b,c){"use strict";function d(){if(!a.querySelector||!a.body.getBoundingClientRect)return!1;c.dataLayerName=c.dataLayerName||"dataLayer",c.distances=c.distances||{},h(),o(b,"scroll",n(h,500))}function e(a,b){var d,e,h,i=s()||{},j=[],k=[];if(c.distances.percentages&&(c.distances.percentages.each&&(j=j.concat(c.distances.percentages.each)),c.distances.percentages.every))for(h=0;ha&&!r[b]&&a<(d||1/0)&&a>(f||0)&&(r[b]=!0,i(b))}function i(a){var d=b.GoogleAnalyticsObject;void 0===b[c.dataLayerName]||c.forceSyntax?"function"==typeof b[d]&&"function"==typeof b[d].getAll&&2!==c.forceSyntax?b[d]("send","event",c.category,a,c.label,{nonInteraction:1}):void 0!==b._gaq&&1!==c.forceSyntax&&b._gaq.push(["_trackEvent",c.category,a,c.label,0,!0]):b[c.dataLayerName].push({event:"scrollTracking",attributes:{distance:a,label:c.label}})}function j(b){if("number"==typeof b||parseInt(b,10))return parseInt(b,10);try{return p(1===b.nodeType?b:a.querySelector(b))}catch(c){return}}function k(){var c=void 0!==b.pageXOffset,d="CSS1Compat"===(a.compatMode||""),e=c?b.pageYOffset:d?a.documentElement.scrollTop:a.body.scrollTop;return parseInt(e,10)+parseInt(l(),10)}function l(){return("CSS1Compat"===a.compatMode?a.documentElement:a.body).clientHeight}function m(b,c){var d=a.body,e=a.documentElement,f=Math.max(d.scrollHeight,d.offsetHeight,e.clientHeight,e.scrollHeight,e.offsetHeight);return c&&(f-=c),b&&(f=b-(c||0)),f-5}function n(a,b){var c,d,e,f=null,g=0,h=function(){g=new Date,f=null,e=a.apply(c,d)};return function(){var i=new Date;g||(g=i);var j=b-(i-g);return c=this,d=arguments,j<=0?(clearTimeout(f),f=null,g=i,e=a.apply(c,d)):f||(f=setTimeout(h,j)),e}}function o(a,b,c){a.addEventListener?a.addEventListener(b,c):a.attachEvent?a.attachEvent("on"+b,function(b){c.call(a,b)}):void 0!==a["on"+b]&&null!==a["on"+b]||(a["on"+b]=function(b){c.call(a,b)})}function p(c){return c.getBoundingClientRect().top+(void 0!==b.pageYOffset?b.pageYOffset:(a.documentElement||a.body.parentNode||a.body).scrollTop)}function q(a){return a instanceof Array}var r={};c.distances.percentages&&c.distances.percentages.every&&(q(c.distances.percentages.every)||(c.distances.percentages.every=[c.distances.percentages.every])),c.distances.pixels&&c.distances.pixels.every&&(q(c.distances.pixels.every)||(c.distances.pixels.every=[c.distances.pixels.every]));var s=function(b){function c(){var c,e,f,g,h,i;if(d={},b.each)for(i=0;i10&&(c(),e=0);for(a in d)b[a]=d[a];return b}}}(c.distances.elements);"loading"!==a.readyState?d():a.addEventListener?o(a,"DOMContentLoaded",d):o(b,"load",d)} -(document, window, { - forceSyntax: false, - dataLayerName: false, - distances: { - percentages: { - each: [10, 90], - every: [25] - }, - pixels: { - each: [], - every: [] - }, - elements: { - each: [], - every: [] - } - }, - top: null, - bottom: null, - category: "Scroll Tracking", - label: document.location.pathname -});/* - * v1.1.2 - * Created by the Google Analytics consultants at http://www.lunametrics.com/ - * Written by @notdanwilkerson - * Documentation: https://github.com/lunametrics/gascroll/ - * Licensed under the MIT License - */ \ No newline at end of file diff --git a/package.json b/package.json index 01db8b5..a0882a0 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "scroll-tracking-google-analytics", - "version": "1.1.2", - "description": "Plug-and-play scroll tracking for Google Analytics with no dependencies.", + "version": "2.0.0", + "description": "Plug-and-play context-aware scroll tracking with no dependencies.", "main": "Gruntfile.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node_modules/karma/bin/karma start karma.conf.js --singleRun" }, "author": "LunaMetrics, LLC", - "license": "CC-BY-4.0", + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/DanWilkerson/youtube-google-analytics.git" @@ -16,7 +16,11 @@ "grunt": "^0.4.5", "grunt-contrib-jshint": "~0.12.0", "grunt-contrib-uglify": "^0.9.1", - "js-beautify": "1.5.7" + "jasmine-core": "^2.8.0", + "karma": "^1.7.1", + "karma-jasmine": "^1.1.0", + "karma-phantomjs-launcher": "^1.0.4", + "phantomjs-prebuilt": "^2.1.15" }, "bugs": { "url": "https://github.com/DanWilkerson/youtube-google-analytics/issues" diff --git a/readme.md b/readme.md index 5a4cae8..5868877 100644 --- a/readme.md +++ b/readme.md @@ -1,294 +1,60 @@ -#Scroll Tracking Google Analytics & GTM Plugin +# Scroll Tracker -Plug-and-play, dependency-free scroll tracking for Google Analytics or Google Tag Manager. Can be customized for custom percentages, custom pixel lengths, and element-based tracking. It will detect if GTM, Universal Analytics, or Classic Analytics is installed on the page, in that order, and use the first syntax it matches unless configured otherwise. It include support for delivering hits directly to Universal or Classic Google Analytics, or for pushing Data Layer events to be used by Google Tag Manager. +Library for observing scrolling behavior. Register handlers to fire when a user scrolls past a custom percentage, pixel depth, or selector-picked element. Compatible with nested scrolling areas (e.g. overflows). Tested on: -Once installed, the plugin will fire events with the following settings: +- IE 9 +- IE 10 +- IE 11 +- Edge 15 +- Firefox 55 +- Chrome 60 +- Opera 47 +- Safari 5.1 9.1 -- Event Category: Scroll Tracking -- Event Action: *<Scroll Percentage or Pixel Depth>* -- Event Label: *<Page Path>* +To get started, install the script in your project and set up a tracker. -Marker locations are refreshed every time the listener is called, so dynamic content should be trackable out of the box. Once a marker has been tracked, it is blocked from firing on subsequent checks. Tracking does not account for the starting position of the viewport; if the browser loads the viewport at the bottom of the page and the user triggers a scroll event, all percentages up to that point in the document will be tracked. + var tracker = window.ScrollTracker(); -##Installation +Then register a handler on the scrolling events you'd like to observe. -This plugin is designed to be plug-and-play. By default, the plugin will try and detect if your site has Google Tag Manager, Universal Analytics, or Classic Analytics, and it will send the data to the first source it matches in that order. - -###Google Tag Manager Installation - -####Container Import (recommended) - -1. Download the file 'luna-scroll-tracking.json' from this repository. -2. In Google Tag Manager, navigate to the **Admin** tab. -3. Under the **Container** column, select **Import Container**. -4. Click **Choose Container File** and select the 'luna-scroll-tracking.json' file you downloaded. -5. Select **Merge** from the radio selector beneath the Choose Container File button. -6. Select **Rename** from the radio selector that appears beneath the Merge selector. -7. Click Continue, then Confirm. -8. Navigate to the Tags interface - select the tag imported tag named GA Event - Scroll Tracking. -9. Change the {{YOUR_GA_TRACKING_ID}} in the **Tracking ID** field to your Google Analytics Tracking ID (a.k.a. UA Number). - -Once you publish your next container, scroll tracking will begin working immediately. - -**NOTE:** If you're using a custom GA cookie name, GA cookie domain, or GA function name, you'll need to change those variables as well. - -####Manual Installation (not recommended) - -#####Adding the Script -1. In Google Tag Manager, create a new Custom HTML tag. Name it 'CU - Scroll Tracking - LunaMetrics Plugin'. -2. Copy the below into the blank Custom HTML tag: - - - -3. Copy to your clipboard the entire contents of (https://github.com/lunametrics/gascroll/lunametrics-scroll-tracking.gtm.js)[the scroll tracking script, located here]. -4. Highlight the line with **HIGHLIGHT THIS ENTIRE LINE** printed inside and paste the contents of the script over it. -5. Save the tag with the trigger **All Pages**. - -#####Configuring GTM -Create the following Trigger in Google Tag Manager: - -* Trigger Name: Event - Scroll Tracking - - Trigger Type: Custom Event - - Event Name: scrollTracking - -Create the following Variables in Google Tag Manager: - -* Variable Name: Scroll Distance - - Variable Type: Data Layer - - Data Layer Variable Name: attributes.distance - -* Variable Name: Scroll Label - - Variable Type: Data Layer - - Data Layer Variable Name: attributes.label - -Create the following Tag in Google Tag Manager: - -* Tag Name: GA Event - Scroll Tracking - - Tag Type: Google Analytics - - Choose A Tag Type: Universal Analytics - - Tracking ID: *< Enter your Google Analytics Tracking ID (UA Number)*> - - Track Type: Event - - Category: Scroll Tracking - - Action: {{Scroll Distance}} - - Label: {{Scroll Label}} - - Non-interaction Hit: True - - More Settings: - - Fields to Set: - - Field Name: cookieDomain - - Field Value: auto - - Fire On: More - - Choose From Existing Triggers: Event - Scroll Tracking - -Please ensure that other tracker settings are uniform in this event, as with your primary tracker (e.g. function name, cookie name, etc.). **Differences between Google Analytics tags in a container can cause difficult to debug tracking inconsistencies**. - -###Universal Analytics/Classic Analytics Installation -To install the Scroll Tracking script on non-GTM implementations, simply include the script in the <head> section of every page you'd like to track. You'll need to host the script on your own server, then include it using a <script> tag that looks something like this: - - - -We have provided a minified version that you can use, too. - -## Configuration - -### Default Configuration -Out of the box, the script will fire an event into Google Analytics whenever a user scrolls down 10%, 25%, 50%, 75%, 90%, and 100% of the page. You can adjust this by changingthe distances.percentages.each array in the configuration object at the bottom of the script. It will try and send those events to the Google Tag Manager Data Layer, then the Universal Analytics global function (typically 'ga'), then the Classic Analytics queue (_gaq), in that order. - -### Scroll Distances - -#### distances.percentages.every -Fires an event every *n*% scrolled. The default setting fires every 25%. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'distances': { - 'percentages': { - 'every': [10, 25] // Fires at the 10%, 20%, 25%, 30%, 40%, 50%, 60%, 70%, 75%, 80%, 90%, and 100% scroll marks - } + tracker.on({ + percentages: { + every: [25] } - }); + }, function(evt) { -#### distances.percentages.each -Fires an event when the user scrolls past each percentage provided in the array. The default setting fires at the 10% and 90% mark. + console.log(evt.data.label); // > "25%" + console.log(evt.data.depth); // > 500 - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'distances': { - 'percentages': { - 'each': [10, 90] // Fires at the 10% and 90% scroll marks - } - } }); -**NOTE**: Google Analytics has a 500 hit per-session limitation, as well as a 20 hit window that replenishes at 2 hits per second. For that reason, it is HIGHLY INADVISABLE to track every 1% of page scrolled. - -#### distances.pixels.every -Fires an event every *n* pixels scrolled vertically. - - (function(document, window, config) { +Additional handlers can be added at any time to additional measurements or measurements that already have a handler. - // ... the tracking code - - })(document, window, { - 'distances': { - 'pixels': { - 'every': [250, 300] // Fires at the 250px, 300px, 500px, 600px, 750px, ... scroll marks. - } - } - }); - -#### distances.pixels.each -Fires an event when the user scrolls past each number of pixels provided in the array, vertically. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'distances': { - 'pixels': { - 'each': [100, 250, 1001] // Fires at the 100px, 250px, and 1001px scroll marks. - } + tracker.on({ + percentages: { + every: [25] + }, + elements: { + each: ['.header', '.footer'] } - }); + }, someNewHandler); -Under the hood, the selector is passed to `document.querySelector`. The resulting locations are cached temporarily to prevent browser shudder. +When a tracker is no longer required it can be destroyed by calling `.destroy()`. -**NOTE**: Google Analytics has a 500 hit per-session limitation, as well as a 20 hit window that replenishes at 2 hits per second. For that reason, it is HIGHLY INADVISABLE to track every pixel of the page scrolled. + tracker.destroy(); -#### distances.elements.every -Fires every time an element matching the given selector is scrolled past +# Google Tag Manager Plugin - (function(document, window, config) { +A pre-build Google Tag Manager container is included in the repository for download to import tracking into Google Tag Manager. The file is named luna-gtm-scroll-tracker.json. - // ... the tracking code +## Installation & Documentation - })(document, window, { - 'distances': { - 'elements': { - 'every': ['.hero-img', '.code-sample > pre'] // Fires when the user scrolls past any elements with the class 'base-img' and any pre elements that are the immediate children of an element with the class 'code-sample'. - } - } - }); - -Under the hood, the selector is passed to `document.querySelectorAll`. The resulting locations are cached temporarily to prevent browser shudder. - -#### distances.elements.each -Fires when the first element to match a given selector is scrolled past - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'distances': { - 'elements': { - 'each': ['#content', '#footer'] // Fires when the #content and #footer elements are scrolled past - } - } - }); - -**NOTE**: Google Analytics has a 500 hit per-session limitation, as well as a 20 hit window that replenishes at 2 hits per second. For that reason, it is HIGHLY INADVISABLE to track every element of the page scrolled. - -### Top/Bottom Of Scrollable Area -This script allows you to specify where to begin and end tracking user scrolling. The default configuration is the entire page. - -#### Top -The top, or starting pixel, is configured by passing a number, element, or CSS query selector. All scroll distances will be begin offset *n* pixels down the page, where *n* is the number provided or the top edge of the element selected. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'top': '#content' // Sets the point to being scroll tracking as the top of the element that matches the query selector '#content' - }); - -#### Bottom -The bottom, or starting pixel, is configured by passing a number, element, or CSS query selector. All scroll distances will be calculated with *n* maximum depth, where *n* is the number provided or the top edge of the element selected. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'bottom': '#footer' // Sets the point to being scroll tracking as the top of the element that matches the query selector '#footer' - }); - -### Event Values -This script allows you to specify a Category and Label for the events that are sent to Google Analytics (or pushed to the Data Layer). - -#### category -The Category value provided to the Event or Data Layer for each scroll tracking event measured. The default setting is 'Scroll Tracking'. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'category': 'Scroll Tracking' // Sets the Google Analytics Event Category to Scroll Tracking - }); - -#### label -The Label value provided to the Event or Data Layer for each scroll tracking event measured. The default setting is the page path. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'category': document.location.pathname // Sets the Google Analytics Event Label to the path of the page - }); - -### Forcing Universal or Classic Analytics Instead of GTM - -By default, the plugin will try and fire Data Layer events, then fallback to Univeral Analytics events, then fallback to Classic Analytics events. If you want to force the script to use a particular syntax for your events, you can set the 'forceSyntax' property of the configuration object to an integer: - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'forceSyntax': 1 // Forces the script to send hits using Universal syntax - }); - -Setting this value to 0 will force the script to use Google Tag Manager events, setting it 1 will force it to use Universal Google Analytics events, and setting it to 2 will force it to use Classic Google Analytics events. - -### Using A Custom Data Layer Name (GTM Only) -If you're using a name for your dataLayer object other than 'dataLayer', you must configure the script to push the data into the correct place. Otherwise, it will try Universal Analytics directly, then Classic Analytics, and then fail silently. - - (function(document, window, config) { - - // ... the tracking code - - })(document, window, { - 'dataLayerName': 'customDataLayerName' // The script will try and push the event data into window.customDataLayerName - }); +For installation instructions and complete documentation, visit [http://www.lunametrics.com/labs/recipes/scroll-tracking/#documentation](http://www.lunametrics.com/labs/recipes/scroll-tracking/#documentation). ## License Licensed under the MIT License. For the full text of the license, view the LICENSE.MD file included with this repository. -## Browser Support - -This library supports all features and is tested on the following browsers: -- IE8 -- IE9 -- IE10 -- IE11 -- Edge -- Chrome -- Firefox -- Opera -- Safari - ## Acknowledgements Created by the honest folks at [LunaMetrics](http://www.lunametrics.com/), a digital marketing & Google Analytics consultancy. For questions, please drop us a line here or [on our blog](http://www.lunametrics.com/blog/). diff --git a/scroll-tracker.js b/scroll-tracker.js new file mode 100644 index 0000000..e1f74cf --- /dev/null +++ b/scroll-tracker.js @@ -0,0 +1,654 @@ +/** + * Emits events based on scrolling behavior in a given context. Shouldn't + * be called until after DOMReady. + * + * @example + * var scrollTracker = ScrollTracker({ + * context: '#content' + * }); + * + * scrollTracker.on({ + * percentage: { + * every: [25] + * } + * }, function(evt) { + * + * // Will trigger when the user reaches 25, 50, 75, & 100% depth + * notifySomeService(evt.data.scrollDepth); + * + * }); + * + * Copyright(c) 2017 LunaMetrics, LLC. + * Written by @notdanwilkerson + * Licensed under the MIT License + * For full license text, visit https://opensource.org/licenses/MIT + */ +(function(window) { + + 'use strict'; + // Won't work on IE8, so we install a mock. + if (window.navigator.userAgent.match(/MSIE [678]/gi)) return installMock(); + + var document = window.document; + + /** + * @constructor + * + * @param {object} [opts] options for the constructor + * @param {HTMLElement} [opts.context] defaults to + * @param {number} [opts.minHeight] minimum height of context required to track + * + * @returns {ScrollTracker} + */ + function ScrollTracker(opts) { + + if (!(this instanceof ScrollTracker)) return new ScrollTracker(opts); + + opts = opts || {}; + + var context = opts.context || 'body'; + + if (typeof context === 'string') context = document.querySelector(context); + + if (!context) throw new Error('Unable to find context ' + context); + + this._context = context; + this.minHeight = opts.minHeight || 0; + this._marks = {}; + this._tracked = {}; + this._config = { + percentages: { + each: {}, + every: {} + }, + pixels: { + each: {}, + every: {} + }, + elements: { + each: {}, + every: {} + } + }; + + var boundAndThrottledDepthCheck = throttle(this._checkDepth.bind(this), 500); + var boundUpdate = this._update.bind(this); + var throttledUpdate = throttle(boundUpdate, 500); + + window.addEventListener('scroll', boundAndThrottledDepthCheck, true); + window.addEventListener('resize', throttledUpdate); + + this._artifacts = { + timer: onDocHeightChange(boundUpdate), + resize: throttledUpdate, + scroll: boundAndThrottledDepthCheck + }; + + } + + /** + * Cleans up timer and event bindings + */ + ScrollTracker.prototype.destroy = function() { + + clearInterval(this._artifacts._timer); + window.removeEventListener('resize', this._artifacts.resize); + window.removeEventListener('scroll', this._artifacts.scroll, true); + + }; + + /** + * Registers a handler for a given configuration + * + * @param {object} config + * @param {object} [config.percentages] + * @param {number[]} [config.percentages.each] tracks every 100 / n percentage + * @param {number[]} [config.percentages.every] tracks each percentage once + * @param {object} [config.pixels] + * @param {number[]} [config.pixels.each] tracks every context.height() / n pixel depth + * @param {number[]} [config.pixels.every] tracks each pixel depth once + * @param {object} [config.elements] + * @param {string[]} [config.elements.each] tracks every element that matches each selector + * @param {string[]} [config.elements.every] tracks the first element that matches each selector + * @param {function} handler + */ + ScrollTracker.prototype.on = function(config, handler) { + + var _config = this._config; + + ['percentages', 'pixels', 'elements'].forEach(function(type) { + + if (!config[type]) return; + + ['each', 'every'].forEach(function(freq) { + + if (!config[type][freq]) return; + + config[type][freq].forEach(function(key) { + + _config[type][freq][key] = _config[type][freq][key] || []; + _config[type][freq][key].push(handler); + + }); + + }); + + }); + + this._update(); + + }; + + /** + * Checks marks and depth + */ + ScrollTracker.prototype._update = function() { + + this._calculateMarks(); + this._checkDepth(); + + }; + + /** + * Calculates the pixels for all configs + */ + ScrollTracker.prototype._calculateMarks = function() { + + delete this._marks; + this._fromTop = getNodeDistanceFromTop(this._context); + this._marks = {}; + + var _config = this._config; + var contextHeight = this._contextHeight(); + var addMark = this._addMark.bind(this); + var self = this; + var elements, + element, + depth, + key; + + if (contextHeight < this.minHeight) return; + + for (key in _config.percentages.every) { + + forEachIn({ + n: Number(key), + numerator: 100, + callback: percentagesEveryCallback(_config.percentages.every[key]) + }); + + } + + for (key in _config.pixels.every) { + + forEachIn({ + n: Number(key), + numerator: contextHeight, + callback: pixelsEveryCallback(_config.pixels.every[key]) + }); + + } + + for (key in _config.percentages.each) { + + depth = Math.floor(contextHeight * Number(key) / 100); + + addMark({ + label: key + '%', + depth: depth, + handlers: _config.percentages.each[key] + }); + + } + + for (key in _config.pixels.each) { + + depth = Number(key); + + addMark({ + label: key + 'px', + depth: depth, + handlers: _config.pixels.each[key] + }); + + } + + for (key in _config.elements.every) { + + elements = [].slice.call(this._context.querySelectorAll(key)); + + if (elements.length) { + + elements.forEach(elementsEveryCallback(key, _config.elements.every[key])); + + } + + } + + for (key in _config.elements.each) { + + element = this._context.querySelector(key); + + if (element) { + + depth = element.getBoundingClientRect().top - + self._context.getBoundingClientRect().top; + + addMark({ + label: key, + depth: depth, + handlers: _config.elements.each[key] + }); + + } + + } + + /** + * Callback for our everyElements iterations + * + * @param {string} key + * @param {function[]} handlers + * + * @returns {everyElement~Callback} + */ + function elementsEveryCallback(key, handlers) { + + /** + * @callback everyElement~Callback + * + * @param {HTMLElement} element + * @param {number} ind + */ + return function(element, ind) { + + var depth = element.getBoundingClientRect().top - + self._context.getBoundingClientRect().top; + + addMark({ + label: key + '[' + ind + ']', + depth: depth, + handlers: _config.elements.every[key] + }); + + }; + + } + + + /** + * Builds a callback for our everyPercentages iterations + * + * @param {function[]} handlers + * + * @returns {everyPercentage~Callback} + */ + function percentagesEveryCallback(handlers) { + + /** + * @callback everyPercentage~Callback + * + * @param {number} n + */ + return function(n) { + + var depth = Math.floor(n * contextHeight / 100); + + addMark({ + label: String(n) + '%', + depth: depth, + handlers: _config.percentages.every[key] + }); + + }; + + } + + /** + * Builds a callback for our everyPixels iterations + * + * @param {function[]} handlers + * + * @param {number} n + */ + + function pixelsEveryCallback(handlers) { + + /** + * @callback everyPixel~Callback + * + * @param {function[]} handlers + */ + return function(n) { + + var depth = n; + + addMark({ + label: String(depth) + 'px', + depth: depth, + handlers: handlers + }); + + }; + + } + + }; + + /** + * Checks all marks and triggers appropriate handlers + */ + ScrollTracker.prototype._checkDepth = function() { + + var marks = this._marks; + var currentDepth = this._currentDepth(); + var key; + + for (key in marks) { + + if (currentDepth >= key && !this._tracked[key]) { + + marks[key].forEach(function(boundHandler) { + boundHandler(); + }); + + this._tracked[key] = true; + + } + + } + + }; + + /** + * Resets the internal cache of tracked marks + */ + ScrollTracker.prototype.reset = function() { + + this._tracked = {}; + + }; + + /** + * Returns the height of the scrolling context + * + * @returns {number} + */ + ScrollTracker.prototype._contextHeight = function() { + + if (this._context !== document.body) return this._context.scrollHeight - 5; + + return this._context.clientHeight - 5; + + }; + + /** + * Returns the current depth we've scrolled into the context + * + * @returns {number} + */ + ScrollTracker.prototype._currentDepth = function() { + + var isVisible = visibleInViewport(this._context); + var depth; + + if (!this._context.scrollTop) { + + this._context.scrollTop = 1; + + if (!this._context.scrollTop) { + + depth = (window.pageYOffset || + document.documentElement.scrollTop || + document.body.scrollTop || 0); + + } else { + + this._context.scrollTop = 0; + depth = this._context.scrollTop + isVisible; + + } + + } else { + + depth = this._context.scrollTop + isVisible; + + } + + if (!isVisible) { + + return depth >= this._fromTop ? depth : -1; + + } + + return depth + isVisible; + + }; + + /** + * Adds a mark to be tracked + * + * @param {object} config + * @param {number} config.depth + * @param {string} config.label + * @param {function[]} config.handlers + */ + ScrollTracker.prototype._addMark = function(config) { + + var depth = config.depth; + + this._marks[depth] = (this._marks[depth] || []).concat(Mark(config)); + + }; + + /** + * @constructor + * + * @param {object} config + * @param {string} config.label + * @param {number} config.depth + * @param {function[]} config.handlers + * + * @returns {Mark} + */ + function Mark(config) { + + /** + * A Mark is an array of callbacks bound with their data payloads + * + * @name Mark + * + * @type {function[]} + */ + return config.handlers.map(function(handler) { + + return handler.bind(this, { + data: { + depth: config.depth, + label: config.label + } + }); + + }); + + } + + /** + * Calls a callback function each time config.n goes into config.numerator + * + * @param {object} config + * @param {number} config.n + * @param {number} config.numerator + * @param {function} config.callback + */ + function forEachIn(config) { + + var len = Math.floor(config.numerator / config.n); + var i; + + for (i = 1; i <= len; i++) { + + config.callback(i * config.n); + + } + + } + + /** + * Helper that watches for changes in the height of the document + */ + function onDocHeightChange(handler) { + + var documentHeight = docHeight(); + + return setInterval(function() { + + if (docHeight() !== documentHeight) { + + handler(); + documentHeight = docHeight(); + + } + + }, 500); + + } + + /** + * Returns the height of the document + * + * @returns {number} + */ + function docHeight() { + + var body = document.body; + var html = document.documentElement; + + return Math.max(body.scrollHeight, body.offsetHeight, + html.clientHeight, html.scrollHeight, html.offsetHeight); + + } + + /** + * Returns the number of pixels of the element visible in the viewport + * @param {HTMLElement} element + * + * @returns {number} + * adapted from: + * @link https://stackoverflow.com/questions/24768795/get-the-visible-height-of-a-div-with-jquery#answer-26831113 + */ + function visibleInViewport(element) { + + var height = element.offsetHeight; + var windowHeight = viewportHeight(); + var rect = element.getBoundingClientRect(); + + return Math.max( + 0, + rect.top > 0 ? Math.min(height, windowHeight - rect.top) : + (rect.bottom < windowHeight ? rect.bottom : windowHeight) + ); + + } + + /** + * Returns the height of the viewport + * + * @returns {number} + */ + function viewportHeight() { + + var elem = (document.compatMode === "CSS1Compat") ? + document.documentElement : + document.body; + + return elem.clientHeight; + + } + + /** + * Retrieves the distance of a node from the top of the document + * + * @param {HTMLElement} node + * + * @returns {number} + */ + function getNodeDistanceFromTop(node) { + + var nodeTop = node.getBoundingClientRect().top; + // @link https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX + var docTop = (window.pageYOffset !== undefined) ? + window.pageYOffset : + (document.documentElement || document.body.parentNode || document.body).scrollTop; + + return nodeTop + docTop; + + } + + /** + * Does nothing + */ + function noop() {} + + /** + * Throttle function borrowed from: + * Underscore.js 1.5.2 + * http://underscorejs.org + * (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Underscore may be freely distributed under the MIT license. + */ + function throttle(func, wait) { + var context, args, result; + var timeout = null; + var previous = 0; + var later = function() { + previous = new Date; + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date; + if (!previous) previous = now; + var remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + } + + /** + * Installs a noop'd version of ScrollTracker on the window + */ + function installMock() { + + var fake = {}; + var key; + + for (key in ScrollTracker) { + + fake[key] = noop; + + } + + window.ScrollTracker = fake; + + } + + window.ScrollTracker = ScrollTracker; + +})(this); +/* + * v2.0.0 + * Created by the Google Analytics consultants at http://www.lunametrics.com/ + * Written by @notdanwilkerson + * Documentation: https://github.com/lunametrics/gascroll/ + * Licensed under the MIT License + */ \ No newline at end of file diff --git a/scroll-tracker.min.js b/scroll-tracker.min.js new file mode 100644 index 0000000..ea2c355 --- /dev/null +++ b/scroll-tracker.min.js @@ -0,0 +1,8 @@ +!function(a){"use strict";function b(c){if(!(this instanceof b))return new b(c);c=c||{};var d=c.context||"body";if("string"==typeof d&&(d=m.querySelector(d)),!d)throw new Error("Unable to find context "+d);this._context=d,this.minHeight=c.minHeight||0,this._marks={},this._tracked={},this._config={percentages:{each:{},every:{}},pixels:{each:{},every:{}},elements:{each:{},every:{}}};var f=k(this._checkDepth.bind(this),500),g=this._update.bind(this),h=k(g,500);a.addEventListener("scroll",f,!0),a.addEventListener("resize",h),this._artifacts={timer:e(g),resize:h,scroll:f}}function c(a){return a.handlers.map(function(b){return b.bind(this,{data:{depth:a.depth,label:a.label}})})}function d(a){var b,c=Math.floor(a.numerator/a.n);for(b=1;b<=c;b++)a.callback(b*a.n)}function e(a){var b=f();return setInterval(function(){f()!==b&&(a(),b=f())},500)}function f(){var a=m.body,b=m.documentElement;return Math.max(a.scrollHeight,a.offsetHeight,b.clientHeight,b.scrollHeight,b.offsetHeight)}function g(a){var b=a.offsetHeight,c=h(),d=a.getBoundingClientRect();return Math.max(0,d.top>0?Math.min(b,c-d.top):d.bottom=a&&!this._tracked[a]&&(b[a].forEach(function(a){a()}),this._tracked[a]=!0)},b.prototype.reset=function(){this._tracked={}},b.prototype._contextHeight=function(){return this._context!==m.body?this._context.scrollHeight-5:this._context.clientHeight-5},b.prototype._currentDepth=function(){var b,c=g(this._context);return this._context.scrollTop?b=this._context.scrollTop+c:(this._context.scrollTop=1,this._context.scrollTop?(this._context.scrollTop=0,b=this._context.scrollTop+c):b=a.pageYOffset||m.documentElement.scrollTop||m.body.scrollTop||0),c?b+c:b>=this._fromTop?b:-1},b.prototype._addMark=function(a){var b=a.depth;this._marks[b]=(this._marks[b]||[]).concat(c(a))},a.ScrollTracker=b}(this); +/* + * v2.0.0 + * Created by the Google Analytics consultants at http://www.lunametrics.com/ + * Written by @notdanwilkerson + * Documentation: https://github.com/lunametrics/gascroll/ + * Licensed under the MIT License + */ \ No newline at end of file diff --git a/src/lunametrics-scroll-tracking.gtm.js b/src/lunametrics-scroll-tracking.gtm.js deleted file mode 100644 index fe74752..0000000 --- a/src/lunametrics-scroll-tracking.gtm.js +++ /dev/null @@ -1,512 +0,0 @@ -;(function(document, window, config) { - - 'use strict'; - - // Global cache we'll use to ensure no double-tracking occurs - var MarksAlreadyTracked = {}; - - // Backwards compatible with old every setting, which was single value - if (config.distances.percentages && config.distances.percentages.every) { - - if (!isArray_(config.distances.percentages.every)) { - - config.distances.percentages.every = [config.distances.percentages.every]; - - } - - } - - // Backwards compatible with old every setting, which was single value - if (config.distances.pixels && config.distances.pixels.every) { - - if (!isArray_(config.distances.pixels.every)) { - - config.distances.pixels.every = [config.distances.pixels.every]; - - } - - } - - // Get a hold of any relevant elements, if specified in config - var elementDistances = (function(selectors) { - - // If no selectors specified, short circuit here - if (!selectors) return; - - // Create a cache to store positions of elements temporarily - var cache = {}; - var counter = 0; - - // Fetch latest positions - _update(); - - // Return a function that can be called to get a map of element positions - return function () { - - // Clone here to prevent inheritance from getMarks step - var shallowClone = {}; - var key; - - counter++; - - // If temp cache counter is greater than 10, re-poll elements - if (counter > 10) { - - _update(); - counter = 0; - - } - - for (key in cache) { - - shallowClone[key] = cache[key]; - - } - - return shallowClone; - - }; - - function _update() { - - var selector, - markName, - els, - el, - y, - i; - // Clear current cache - cache = {}; - - if (selectors.each) { - - for (i = 0; i < selectors.each.length; i++) { - - selector = selectors.each[i]; - - if (!MarksAlreadyTracked[selector]) { - - el = document.querySelector(selector); - - if (el) cache[selector] = getNodeDistanceFromTop(el); - - } - - } - - } - - if (selectors.every) { - - for (i = 0; i < selectors.every.length; i++) { - - selector = selectors.every[i]; - els = document.querySelectorAll(selector); - - // If the last item in the selected group has been tracked, we skip it - if (els.length && !MarksAlreadyTracked[selector + ':' + (els.length - 1)]) { - - for (y = 0; y < els.length; y++) { - - markName = selector + ':' + y; - - // We also check at the individual element level - if (!MarksAlreadyTracked[markName]) { - - el = els[y]; - cache[markName] = getNodeDistanceFromTop(el); - - } - - } - - } - - } - - } - - } - - })(config.distances.elements); - - // If our document is ready to go, fire straight away - if(document.readyState !== 'loading') { - - init(); - - } else { - - // On IE8 this fires on window.load, all other browsers will fire when DOM ready - document.addEventListener ? - addEvent(document, 'DOMContentLoaded', init) : - addEvent(window, 'load', init); - - } - - function init() { - - // Browser dependencies, script fails silently - if (!document.querySelector || !document.body.getBoundingClientRect) { - - return false; - - } - - // Set our dataLayer name for later - config.dataLayerName = config.dataLayerName || 'dataLayer'; - - // Initialize our distances, for later - config.distances = config.distances || {}; - - checkDepth(); - addEvent(window, 'scroll', throttle(checkDepth, 500)); - - } - - function getMarks(_docHeight, _offset) { - - var marks = elementDistances() || {}; - var percents = []; - var pixels = []; - var everyPercent, - everyPixel, - i; - - if(config.distances.percentages) { - - if(config.distances.percentages.each) { - - percents = percents.concat(config.distances.percentages.each); - - } - - if(config.distances.percentages.every) { - - for (i = 0; i < config.distances.percentages.every.length; i++) { - - everyPercent = every_(config.distances.percentages.every[i], 100); - percents = pixels.concat(everyPercent); - - } - - } - - } - - if(config.distances.pixels) { - - if(config.distances.pixels.each) { - - pixels = pixels.concat(config.distances.pixels.each); - - } - - if(config.distances.pixels.every) { - - for (i = 0; i < config.distances.pixels.every.length; i++) { - - everyPixel = every_(config.distances.pixels.every[i], _docHeight); - pixels = pixels.concat(everyPixel); - - } - - } - - } - - marks = addMarks_(marks, percents, '%', _docHeight, _offset); - marks = addMarks_(marks, pixels, 'px', _docHeight, _offset); - - return marks; - - } - - function addMarks_(marks, points, symbol, _docHeight, _offset) { - - var i; - - for(i = 0; i < points.length; i++) { - - var _point = parseInt(points[i], 10); - var height = symbol !== '%' ? _point + _offset : _docHeight * (_point / 100) + _offset; - var mark = _point + symbol; - - if(height <= _docHeight + _offset) { - - marks[mark] = height; - - } - - } - - return marks; - - } - - function every_(n, total) { - - var _n = parseInt(n, 10); - var _num = total / _n; - var arr = []; - var i; - - for(i = 1; i < _num + 1; i++) { - - arr.push(i * _n); - - } - - return arr; - - } - - function checkDepth() { - - var _bottom = parseScrollBorder(config.bottom); - var _top = parseScrollBorder(config.top); - var height = docHeight(_bottom, _top); - var marks = getMarks(height, (_top || 0)); - var _curr = currentPosition(); - var target, - key; - - for(key in marks) { - - target = marks[key]; - - // If we've scrolled past the mark, we haven't tracked it yet, and it's in range, track the mark - if( - _curr > target && - !MarksAlreadyTracked[key] && - target < (_bottom || Infinity) && - target > (_top || 0) - ) { - - MarksAlreadyTracked[key] = true; - fireAnalyticsEvent(key); - - } - - } - - } - - function fireAnalyticsEvent(distance) { - - var _ga = window.GoogleAnalyticsObject; - - if(typeof window[config.dataLayerName] !== 'undefined' && !config.forceSyntax) { - - window[config.dataLayerName].push( { - 'event': 'scrollTracking', - 'attributes': { - 'distance': distance, - 'label': config.label - } - }); - - } else if (typeof window[_ga] === 'function' && - typeof window[_ga].getAll === 'function' && - config.forceSyntax !== 2) - { - - window[_ga]('send', 'event', config.category, distance, config.label, {'nonInteraction': 1}); - - } else if(typeof window._gaq !== 'undefined' && config.forceSyntax !== 1) { - - window._gaq.push(['_trackEvent', config.category, distance, config.label, 0, true]); - - } - - } - - function parseScrollBorder(border) { - - if(typeof border === 'number' || parseInt(border, 10)) { - - return parseInt(border, 10); - - } - - try { - - // If we have an element or a query selector, poll getBoundingClientRect - var el = border.nodeType === 1 ? border : document.querySelector(border); - - return getNodeDistanceFromTop(el); - - } catch (e) { - - return void(0); - - } - - } - - // Adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY - function currentPosition() { - - var supportPageOffset = window.pageXOffset !== undefined; - var isCSS1Compat = ((document.compatMode || "") === "CSS1Compat"); - - var currScrollTop = supportPageOffset ? - window.pageYOffset : - isCSS1Compat ? - document.documentElement.scrollTop : - document.body.scrollTop; - - return parseInt(currScrollTop, 10) + parseInt(viewportHeight(), 10); - - } - - function viewportHeight() { - - var elem = (document.compatMode === "CSS1Compat") ? - document.documentElement : - document.body; - - return elem.clientHeight; - - } - - function docHeight(_bottom, _top) { - - var body = document.body; - var html = document.documentElement; - - var height = Math.max(body.scrollHeight, body.offsetHeight, - html.clientHeight, html.scrollHeight, html.offsetHeight); - - - if(_top) { - - height = height - _top; - - } - - if(_bottom) { - - height = _bottom - (_top || 0); - - } - - return height - 5; - - } - - - /* - * Throttle function borrowed from: - * Underscore.js 1.5.2 - * http://underscorejs.org - * (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - * Underscore may be freely distributed under the MIT license. - */ - function throttle(func, wait) { - var context, args, result; - var timeout = null; - var previous = 0; - var later = function() { - previous = new Date; - timeout = null; - result = func.apply(context, args); - }; - return function() { - var now = new Date; - if (!previous) previous = now; - var remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0) { - clearTimeout(timeout); - timeout = null; - previous = now; - result = func.apply(context, args); - } else if (!timeout) { - timeout = setTimeout(later, remaining); - } - return result; - }; - } - - // Cross-browser compliant event listener - function addEvent(el, evt, fn) { - - if (el.addEventListener) { - - el.addEventListener(evt, fn); - - } else if (el.attachEvent) { - - el.attachEvent('on' + evt, function(evt) { - - // Call the event to ensure uniform 'this' handling, pass it event - fn.call(el, evt); - - }); - - } else if (typeof el['on' + evt] === 'undefined' || el['on' + evt] === null) { - - el['on' + evt] = function(evt) { - - // Call the event to ensure uniform 'this' handling, pass it event - fn.call(el, evt); - - }; - - } - - } - - // Helper for fetching top of element - function getNodeDistanceFromTop(node) { - - var nodeTop = node.getBoundingClientRect().top; - // https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX - var docTop = (window.pageYOffset !== undefined) ? - window.pageYOffset : - (document.documentElement || document.body.parentNode || document.body).scrollTop; - - return nodeTop + docTop; - - } - - // Helper to check if something is an Array - function isArray_(thing) { - - return thing instanceof Array; - - } - -})(document, window, { - // Use 2 to force Classic Analytics hits and 1 for Universal hits - 'forceSyntax': false, - // False if you just use the default dataLayer variable, otherwise enter it here - 'dataLayerName': false, - 'distances': { - // Configure percentages of page you'd like to see if users scroll past - 'percentages': { - 'each': [10,90], - 'every': [25] - }, - // Configure for pixel measurements of page you'd like to see if users scroll past - 'pixels': { - 'each': [], - 'every': [] - }, - // Configure elements you'd like to see users scroll past (using CSS Selectors) - 'elements': { - 'each': [], - 'every': [] - } - }, - // Accepts a number, DOM element, or query selector to determine the top of the scrolling area - 'top': null, - // Accepts a number, DOM element, or query selector to determine the bottom of the scrolling area - 'bottom': null, - // Text for Event Category - 'category': 'Scroll Tracking', - // Text for Event Label - 'label': document.location.pathname -}); diff --git a/src/scroll-tracker.js b/src/scroll-tracker.js new file mode 100644 index 0000000..8fecc5c --- /dev/null +++ b/src/scroll-tracker.js @@ -0,0 +1,647 @@ +/** + * Emits events based on scrolling behavior in a given context. Shouldn't + * be called until after DOMReady. + * + * @example + * var scrollTracker = ScrollTracker({ + * context: '#content' + * }); + * + * scrollTracker.on({ + * percentage: { + * every: [25] + * } + * }, function(evt) { + * + * // Will trigger when the user reaches 25, 50, 75, & 100% depth + * notifySomeService(evt.data.scrollDepth); + * + * }); + * + * Copyright(c) 2017 LunaMetrics, LLC. + * Written by @notdanwilkerson + * Licensed under the MIT License + * For full license text, visit https://opensource.org/licenses/MIT + */ +(function(window) { + + 'use strict'; + // Won't work on IE8, so we install a mock. + if (window.navigator.userAgent.match(/MSIE [678]/gi)) return installMock(); + + var document = window.document; + + /** + * @constructor + * + * @param {object} [opts] options for the constructor + * @param {HTMLElement} [opts.context] defaults to + * @param {number} [opts.minHeight] minimum height of context required to track + * + * @returns {ScrollTracker} + */ + function ScrollTracker(opts) { + + if (!(this instanceof ScrollTracker)) return new ScrollTracker(opts); + + opts = opts || {}; + + var context = opts.context || 'body'; + + if (typeof context === 'string') context = document.querySelector(context); + + if (!context) throw new Error('Unable to find context ' + context); + + this._context = context; + this.minHeight = opts.minHeight || 0; + this._marks = {}; + this._tracked = {}; + this._config = { + percentages: { + each: {}, + every: {} + }, + pixels: { + each: {}, + every: {} + }, + elements: { + each: {}, + every: {} + } + }; + + var boundAndThrottledDepthCheck = throttle(this._checkDepth.bind(this), 500); + var boundUpdate = this._update.bind(this); + var throttledUpdate = throttle(boundUpdate, 500); + + window.addEventListener('scroll', boundAndThrottledDepthCheck, true); + window.addEventListener('resize', throttledUpdate); + + this._artifacts = { + timer: onDocHeightChange(boundUpdate), + resize: throttledUpdate, + scroll: boundAndThrottledDepthCheck + }; + + } + + /** + * Cleans up timer and event bindings + */ + ScrollTracker.prototype.destroy = function() { + + clearInterval(this._artifacts._timer); + window.removeEventListener('resize', this._artifacts.resize); + window.removeEventListener('scroll', this._artifacts.scroll, true); + + }; + + /** + * Registers a handler for a given configuration + * + * @param {object} config + * @param {object} [config.percentages] + * @param {number[]} [config.percentages.each] tracks every 100 / n percentage + * @param {number[]} [config.percentages.every] tracks each percentage once + * @param {object} [config.pixels] + * @param {number[]} [config.pixels.each] tracks every context.height() / n pixel depth + * @param {number[]} [config.pixels.every] tracks each pixel depth once + * @param {object} [config.elements] + * @param {string[]} [config.elements.each] tracks every element that matches each selector + * @param {string[]} [config.elements.every] tracks the first element that matches each selector + * @param {function} handler + */ + ScrollTracker.prototype.on = function(config, handler) { + + var _config = this._config; + + ['percentages', 'pixels', 'elements'].forEach(function(type) { + + if (!config[type]) return; + + ['each', 'every'].forEach(function(freq) { + + if (!config[type][freq]) return; + + config[type][freq].forEach(function(key) { + + _config[type][freq][key] = _config[type][freq][key] || []; + _config[type][freq][key].push(handler); + + }); + + }); + + }); + + this._update(); + + }; + + /** + * Checks marks and depth + */ + ScrollTracker.prototype._update = function() { + + this._calculateMarks(); + this._checkDepth(); + + }; + + /** + * Calculates the pixels for all configs + */ + ScrollTracker.prototype._calculateMarks = function() { + + delete this._marks; + this._fromTop = getNodeDistanceFromTop(this._context); + this._marks = {}; + + var _config = this._config; + var contextHeight = this._contextHeight(); + var addMark = this._addMark.bind(this); + var self = this; + var elements, + element, + depth, + key; + + if (contextHeight < this.minHeight) return; + + for (key in _config.percentages.every) { + + forEachIn({ + n: Number(key), + numerator: 100, + callback: percentagesEveryCallback(_config.percentages.every[key]) + }); + + } + + for (key in _config.pixels.every) { + + forEachIn({ + n: Number(key), + numerator: contextHeight, + callback: pixelsEveryCallback(_config.pixels.every[key]) + }); + + } + + for (key in _config.percentages.each) { + + depth = Math.floor(contextHeight * Number(key) / 100); + + addMark({ + label: key + '%', + depth: depth, + handlers: _config.percentages.each[key] + }); + + } + + for (key in _config.pixels.each) { + + depth = Number(key); + + addMark({ + label: key + 'px', + depth: depth, + handlers: _config.pixels.each[key] + }); + + } + + for (key in _config.elements.every) { + + elements = [].slice.call(this._context.querySelectorAll(key)); + + if (elements.length) { + + elements.forEach(elementsEveryCallback(key, _config.elements.every[key])); + + } + + } + + for (key in _config.elements.each) { + + element = this._context.querySelector(key); + + if (element) { + + depth = element.getBoundingClientRect().top - + self._context.getBoundingClientRect().top; + + addMark({ + label: key, + depth: depth, + handlers: _config.elements.each[key] + }); + + } + + } + + /** + * Callback for our everyElements iterations + * + * @param {string} key + * @param {function[]} handlers + * + * @returns {everyElement~Callback} + */ + function elementsEveryCallback(key, handlers) { + + /** + * @callback everyElement~Callback + * + * @param {HTMLElement} element + * @param {number} ind + */ + return function(element, ind) { + + var depth = element.getBoundingClientRect().top - + self._context.getBoundingClientRect().top; + + addMark({ + label: key + '[' + ind + ']', + depth: depth, + handlers: _config.elements.every[key] + }); + + }; + + } + + + /** + * Builds a callback for our everyPercentages iterations + * + * @param {function[]} handlers + * + * @returns {everyPercentage~Callback} + */ + function percentagesEveryCallback(handlers) { + + /** + * @callback everyPercentage~Callback + * + * @param {number} n + */ + return function(n) { + + var depth = Math.floor(n * contextHeight / 100); + + addMark({ + label: String(n) + '%', + depth: depth, + handlers: _config.percentages.every[key] + }); + + }; + + } + + /** + * Builds a callback for our everyPixels iterations + * + * @param {function[]} handlers + * + * @param {number} n + */ + + function pixelsEveryCallback(handlers) { + + /** + * @callback everyPixel~Callback + * + * @param {function[]} handlers + */ + return function(n) { + + var depth = n; + + addMark({ + label: String(depth) + 'px', + depth: depth, + handlers: handlers + }); + + }; + + } + + }; + + /** + * Checks all marks and triggers appropriate handlers + */ + ScrollTracker.prototype._checkDepth = function() { + + var marks = this._marks; + var currentDepth = this._currentDepth(); + var key; + + for (key in marks) { + + if (currentDepth >= key && !this._tracked[key]) { + + marks[key].forEach(function(boundHandler) { + boundHandler(); + }); + + this._tracked[key] = true; + + } + + } + + }; + + /** + * Resets the internal cache of tracked marks + */ + ScrollTracker.prototype.reset = function() { + + this._tracked = {}; + + }; + + /** + * Returns the height of the scrolling context + * + * @returns {number} + */ + ScrollTracker.prototype._contextHeight = function() { + + if (this._context !== document.body) return this._context.scrollHeight - 5; + + return this._context.clientHeight - 5; + + }; + + /** + * Returns the current depth we've scrolled into the context + * + * @returns {number} + */ + ScrollTracker.prototype._currentDepth = function() { + + var isVisible = visibleInViewport(this._context); + var depth; + + if (!this._context.scrollTop) { + + this._context.scrollTop = 1; + + if (!this._context.scrollTop) { + + depth = (window.pageYOffset || + document.documentElement.scrollTop || + document.body.scrollTop || 0); + + } else { + + this._context.scrollTop = 0; + depth = this._context.scrollTop + isVisible; + + } + + } else { + + depth = this._context.scrollTop + isVisible; + + } + + if (!isVisible) { + + return depth >= this._fromTop ? depth : -1; + + } + + return depth + isVisible; + + }; + + /** + * Adds a mark to be tracked + * + * @param {object} config + * @param {number} config.depth + * @param {string} config.label + * @param {function[]} config.handlers + */ + ScrollTracker.prototype._addMark = function(config) { + + var depth = config.depth; + + this._marks[depth] = (this._marks[depth] || []).concat(Mark(config)); + + }; + + /** + * @constructor + * + * @param {object} config + * @param {string} config.label + * @param {number} config.depth + * @param {function[]} config.handlers + * + * @returns {Mark} + */ + function Mark(config) { + + /** + * A Mark is an array of callbacks bound with their data payloads + * + * @name Mark + * + * @type {function[]} + */ + return config.handlers.map(function(handler) { + + return handler.bind(this, { + data: { + depth: config.depth, + label: config.label + } + }); + + }); + + } + + /** + * Calls a callback function each time config.n goes into config.numerator + * + * @param {object} config + * @param {number} config.n + * @param {number} config.numerator + * @param {function} config.callback + */ + function forEachIn(config) { + + var len = Math.floor(config.numerator / config.n); + var i; + + for (i = 1; i <= len; i++) { + + config.callback(i * config.n); + + } + + } + + /** + * Helper that watches for changes in the height of the document + */ + function onDocHeightChange(handler) { + + var documentHeight = docHeight(); + + return setInterval(function() { + + if (docHeight() !== documentHeight) { + + handler(); + documentHeight = docHeight(); + + } + + }, 500); + + } + + /** + * Returns the height of the document + * + * @returns {number} + */ + function docHeight() { + + var body = document.body; + var html = document.documentElement; + + return Math.max(body.scrollHeight, body.offsetHeight, + html.clientHeight, html.scrollHeight, html.offsetHeight); + + } + + /** + * Returns the number of pixels of the element visible in the viewport + * @param {HTMLElement} element + * + * @returns {number} + * adapted from: + * @link https://stackoverflow.com/questions/24768795/get-the-visible-height-of-a-div-with-jquery#answer-26831113 + */ + function visibleInViewport(element) { + + var height = element.offsetHeight; + var windowHeight = viewportHeight(); + var rect = element.getBoundingClientRect(); + + return Math.max( + 0, + rect.top > 0 ? Math.min(height, windowHeight - rect.top) : + (rect.bottom < windowHeight ? rect.bottom : windowHeight) + ); + + } + + /** + * Returns the height of the viewport + * + * @returns {number} + */ + function viewportHeight() { + + var elem = (document.compatMode === "CSS1Compat") ? + document.documentElement : + document.body; + + return elem.clientHeight; + + } + + /** + * Retrieves the distance of a node from the top of the document + * + * @param {HTMLElement} node + * + * @returns {number} + */ + function getNodeDistanceFromTop(node) { + + var nodeTop = node.getBoundingClientRect().top; + // @link https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX + var docTop = (window.pageYOffset !== undefined) ? + window.pageYOffset : + (document.documentElement || document.body.parentNode || document.body).scrollTop; + + return nodeTop + docTop; + + } + + /** + * Does nothing + */ + function noop() {} + + /** + * Throttle function borrowed from: + * Underscore.js 1.5.2 + * http://underscorejs.org + * (c) 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Underscore may be freely distributed under the MIT license. + */ + function throttle(func, wait) { + var context, args, result; + var timeout = null; + var previous = 0; + var later = function() { + previous = new Date; + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date; + if (!previous) previous = now; + var remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + } + + /** + * Installs a noop'd version of ScrollTracker on the window + */ + function installMock() { + + var fake = {}; + var key; + + for (key in ScrollTracker) { + + fake[key] = noop; + + } + + window.ScrollTracker = fake; + + } + + window.ScrollTracker = ScrollTracker; + +})(this); diff --git a/test/scrollTracker.spec.js b/test/scrollTracker.spec.js new file mode 100644 index 0000000..87fd6ee --- /dev/null +++ b/test/scrollTracker.spec.js @@ -0,0 +1,337 @@ +describe('scroll-tracker', function(){ + 'use strict'; + + beforeEach(function(done) { + + var html = '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
1
' + + '
2
' + + '
'; + + document.body.innerHTML = html; + + done(); + + }); + + describe('context, no context, percentage, pixels, and elements', function() { + + it('should be true', function(done) { + + expect(typeof ScrollTracker).toBe('function'); + + var passed = { + normal: {}, + nested: {} + }; + var nestedContext = document.querySelector('.nested'); + var tracker = ScrollTracker(); + var nestedTracker = ScrollTracker({ + context: nestedContext + }); + tracker.on({ + percentages: { + each: [10, 90], + every: [25] + }, + pixels: { + each: [10, 90], + every: [1000] + }, + elements: { + each: ['#each'], + every: ['.every'] + } + }, function(evt) { + + passed.normal[evt.data.label] = evt.data.depth; + + }); + + nestedTracker.on({ + percentages: { + each: [10, 90], + every: [25] + }, + pixels: { + each: [10, 90], + every: [100] + }, + elements: { + each: ['.each'], + every: ['.nested-every'] + } + }, function(evt) { + + passed.nested[evt.data.label] = evt.data.depth; + + }); + + var outcome = { + "nested": { + ".nested-every[0]": 0, + "10px": 10, + "10%": 40, + "90px": 90, + "25%": 100, + "100px": 100, + "50%": 200, + "200px": 200, + ".nested-every[1]": 200, + "75%": 300, + "300px": 300, + "90%": 360, + "100%": 400, + "400px": 400 + }, + "normal": { + "10px": 10, + "90px": 90, + "10%": 190, + "25%": 475, + "50%": 950, + "1000px": 1000, + ".every[0]": 1000, + "#each": 1200, + ".every[1]": 1300, + "75%": 1425, + ".every[2]": 1500, + "90%": 1710, + "100%": 1900 + } + }; + + window.scrollTo(0, 1900); + nestedContext.scrollTop = 400; + + setTimeout(function() { + + expect(passed).toEqual(outcome); + done(); + + }, 1000); + + }); + + }); + + describe('body element', function() { + + it ('should track an element with percentages', function(done) { + + var tracker = ScrollTracker({ + context: '.spacer' + }); + var passed = {}; + + var outcome = { + '10%': 100, + '25%': 250, + '50%': 500, + '75%': 750, + '90%': 900, + '100%': 1000 + }; + + tracker.on({ + percentages: { + every: [25], + each: [10, 90] + } + }, function(evt) { + + passed[evt.data.label] = evt.data.depth; + + }); + + window.scrollTo(0, 1000); + + setTimeout(function() { + + expect(passed).toEqual(outcome); + done(); + + }, 1000); + + }); + + }); + + describe('height change', function() { + + it ('should adjust marks when height changes', function(done) { + + var tracker = ScrollTracker(); + + tracker.on({ + percentages: { + each: [10] + } + }, noop); + + var init = Object.keys(tracker._marks); + var div = document.createElement('div'); + + div.style.height = '2000px'; + + document.body.appendChild(div); + + setTimeout(function() { + + var newKeys = Object.keys(tracker._marks); + + expect(Number(newKeys[0]) - Number(init[0])).toBeGreaterThan(0); + + done(); + + }, 501); + + }); + + }); + + describe('resize event', function() { + + it ('should re-calculate when a resize event occurs', function(done) { + + var tracker = ScrollTracker(); + + tracker.on({ + percentages: { + each: [10] + } + }, noop); + + spyOn(tracker, '_calculateMarks'); + + window.dispatchEvent(new window.Event('resize')); + + setTimeout(function() { + + expect(tracker._calculateMarks).toHaveBeenCalled(); + + done(); + + }, 501); + + }); + + }); + + describe('minHeight', function() { + + it ('should not set marks because the min height is too small', function() { + + var tracker = ScrollTracker({ + minHeight: 2000 + }); + + tracker.on({ + percentages: { + each: [10] + } + }, noop); + + expect(tracker._marks).toEqual({}); + + }); + + it('should set marks because the contextHeight > minHeight', function() { + + var tracker = ScrollTracker({ + minHeight: 1600 + }); + + tracker.on({ + percentages: { + each: [10] + } + }, noop); + + expect(Object.keys(tracker._marks).length).toBeGreaterThan(0); + + }); + + }); + + describe('destroy', function() { + + it ('should remove the timer and events', function() { + + var cachedInterval = window.setInterval; + var cachedClearEvent = window.removeEventListener; + var cachedClearInterval = window.clearInterval; + var intervalCleared = false; + var eventsUnhandled = 0; + var timerId; + + window.setInterval = function(fn, int) { + + timerId = cachedInterval(fn, int); + + return timerId; + + }; + + window.clearInterval = function() { + + intervalCleared = true; + + cachedClearInterval(timerId); + + }; + + window.removeEventListener = function(eventName, fn) { + + eventsUnhandled++; + + return cachedClearEvent(eventName, fn); + + }; + + var tracker = ScrollTracker({ + minHeight: 1600 + }); + + tracker.on({ + percentages: { + each: [10] + } + }, noop); + + tracker.destroy(); + + expect(intervalCleared).toBe(true); + expect(eventsUnhandled).toBe(2); + + }); + + }); + + describe('no context', function() { + + it ('should throw an error if the context is invalid', function(done) { + + try { + ScrollTracker({ + context: '#foobar' + }); + } catch(e) { + + expect(e).toBeDefined(); + done(); + + } + + }); + + }); + +}); + +function noop () {}