UNPKG

scrollama

Version:

Lightweight scrollytelling library using IntersectionObserver

713 lines (604 loc) 20.3 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.scrollama = factory()); }(this, (function () { 'use strict'; // DOM helper functions // private function selectionToArray(selection) { var len = selection.length; var result = []; for (var i = 0; i < len; i += 1) { result.push(selection[i]); } return result; } function selectAll(selector, parent) { if ( parent === void 0 ) parent = document; if (typeof selector === 'string') { return selectionToArray(parent.querySelectorAll(selector)); } else if (selector instanceof Element) { return selectionToArray([selector]); } else if (selector instanceof NodeList) { return selectionToArray(selector); } else if (selector instanceof Array) { return selector; } return []; } function getStepId(ref) { var id = ref.id; var i = ref.i; return ("scrollama__debug-step--" + id + "-" + i); } function getOffsetId(ref) { var id = ref.id; return ("scrollama__debug-offset--" + id); } // SETUP function setupOffset(ref) { var id = ref.id; var offsetVal = ref.offsetVal; var stepClass = ref.stepClass; var el = document.createElement('div'); el.setAttribute('id', getOffsetId({ id: id })); el.setAttribute('class', 'scrollama__debug-offset'); el.style.position = 'fixed'; el.style.left = '0'; el.style.width = '100%'; el.style.height = '0px'; el.style.borderTop = '2px dashed black'; el.style.zIndex = '9999'; var text = document.createElement('p'); text.innerText = "\"." + stepClass + "\" trigger: " + offsetVal; text.style.fontSize = '12px'; text.style.fontFamily = 'monospace'; text.style.color = 'black'; text.style.margin = '0'; text.style.padding = '6px'; el.appendChild(text); document.body.appendChild(el); } function setup(ref) { var id = ref.id; var offsetVal = ref.offsetVal; var stepEl = ref.stepEl; var stepClass = stepEl[0].getAttribute('class'); setupOffset({ id: id, offsetVal: offsetVal, stepClass: stepClass }); } // UPDATE function updateOffset(ref) { var id = ref.id; var offsetMargin = ref.offsetMargin; var offsetVal = ref.offsetVal; var idVal = getOffsetId({ id: id }); var el = document.querySelector(("#" + idVal)); el.style.top = offsetMargin + "px"; } function update(ref) { var id = ref.id; var stepOffsetHeight = ref.stepOffsetHeight; var offsetMargin = ref.offsetMargin; var offsetVal = ref.offsetVal; updateOffset({ id: id, offsetMargin: offsetMargin }); } function notifyStep(ref) { var id = ref.id; var index = ref.index; var state = ref.state; var idVal = getStepId({ id: id, i: index }); var elA = document.querySelector(("#" + idVal + "_above")); var elB = document.querySelector(("#" + idVal + "_below")); var display = state === 'enter' ? 'block' : 'none'; if (elA) { elA.style.display = display; } if (elB) { elB.style.display = display; } } function scrollama() { var OBSERVER_NAMES = [ 'stepAbove', 'stepBelow', 'stepProgress', 'viewportAbove', 'viewportBelow' ]; var cb = { stepEnter: function () {}, stepExit: function () {}, stepProgress: function () {}, }; var io = {}; var id = null; var stepEl = []; var stepOffsetHeight = []; var stepOffsetTop = []; var stepStates = []; var offsetVal = 0; var offsetMargin = 0; var viewH = 0; var pageH = 0; var previousYOffset = 0; var progressThreshold = 0; var isReady = false; var isEnabled = false; var isDebug = false; var progressMode = false; var preserveOrder = false; var triggerOnce = false; var direction = 'down'; var exclude = []; /* HELPERS */ function generateInstanceID() { var a = 'abcdefghijklmnopqrstuv'; var l = a.length; var t = Date.now(); var r = [0, 0, 0].map(function (d) { return a[Math.floor(Math.random() * l)]; }).join(''); return ("" + r + t); } function getOffsetTop(el) { var ref = el.getBoundingClientRect(); var top = ref.top; var scrollTop = window.pageYOffset; var clientTop = document.body.clientTop || 0; return top + scrollTop - clientTop; } function getPageHeight() { var body = document.body; var html = document.documentElement; return Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); } function getIndex(element) { return +element.getAttribute('data-scrollama-index'); } function updateDirection() { if (window.pageYOffset > previousYOffset) { direction = 'down'; } else if (window.pageYOffset < previousYOffset) { direction = 'up'; } previousYOffset = window.pageYOffset; } function disconnectObserver(name) { if (io[name]) { io[name].forEach(function (d) { return d.disconnect(); }); } } function handleResize() { viewH = window.innerHeight; pageH = getPageHeight(); offsetMargin = offsetVal * viewH; if (isReady) { stepOffsetHeight = stepEl.map(function (el) { return el.getBoundingClientRect().height; }); stepOffsetTop = stepEl.map(getOffsetTop); if (isEnabled) { updateIO(); } } if (isDebug) { update({ id: id, stepOffsetHeight: stepOffsetHeight, offsetMargin: offsetMargin, offsetVal: offsetVal }); } } function handleEnable(enable) { if (enable && !isEnabled) { // enable a disabled scroller if (isReady) { // enable a ready scroller updateIO(); } else { // can't enable an unready scroller console.error( 'scrollama error: enable() called before scroller was ready' ); isEnabled = false; return; // all is not well, don't set the requested state } } if (!enable && isEnabled) { // disable an enabled scroller OBSERVER_NAMES.forEach(disconnectObserver); } isEnabled = enable; // all is well, set requested state } function createThreshold(height) { var count = Math.ceil(height / progressThreshold); var t = []; var ratio = 1 / count; for (var i = 0; i < count; i += 1) { t.push(i * ratio); } return t; } /* NOTIFY CALLBACKS */ function notifyStepProgress(element, progress) { var index = getIndex(element); if (progress !== undefined) { stepStates[index].progress = progress; } var resp = { element: element, index: index, progress: stepStates[index].progress }; if (stepStates[index].state === 'enter') { cb.stepProgress(resp); } } function notifyOthers(index, location) { if (location === 'above') { // check if steps above/below were skipped and should be notified first for (var i = 0; i < index; i += 1) { var ss = stepStates[i]; if (ss.state !== 'enter' && ss.direction !== 'down') { notifyStepEnter(stepEl[i], 'down', false); notifyStepExit(stepEl[i], 'down'); } else if (ss.state === 'enter') { notifyStepExit(stepEl[i], 'down'); } // else if (ss.direction === 'up') { // notifyStepEnter(stepEl[i], 'down', false); // notifyStepExit(stepEl[i], 'down'); // } } } else if (location === 'below') { for (var i$1 = stepStates.length - 1; i$1 > index; i$1 -= 1) { var ss$1 = stepStates[i$1]; if (ss$1.state === 'enter') { notifyStepExit(stepEl[i$1], 'up'); } if (ss$1.direction === 'down') { notifyStepEnter(stepEl[i$1], 'up', false); notifyStepExit(stepEl[i$1], 'up'); } } } } function notifyStepEnter(element, dir, check) { if ( check === void 0 ) check = true; var index = getIndex(element); var resp = { element: element, index: index, direction: dir }; // store most recent trigger stepStates[index].direction = dir; stepStates[index].state = 'enter'; if (preserveOrder && check && dir === 'down') { notifyOthers(index, 'above'); } if (preserveOrder && check && dir === 'up') { notifyOthers(index, 'below'); } if (cb.stepEnter && !exclude[index]) { cb.stepEnter(resp, stepStates); if (isDebug) { notifyStep({ id: id, index: index, state: 'enter' }); } if (triggerOnce) { exclude[index] = true; } } if (progressMode) { notifyStepProgress(element); } } function notifyStepExit(element, dir) { var index = getIndex(element); var resp = { element: element, index: index, direction: dir }; if (progressMode) { if (dir === 'down' && stepStates[index].progress < 1) { notifyStepProgress(element, 1); } else if (dir === 'up' && stepStates[index].progress > 0) { notifyStepProgress(element, 0); } } // store most recent trigger stepStates[index].direction = dir; stepStates[index].state = 'exit'; cb.stepExit(resp, stepStates); if (isDebug) { notifyStep({ id: id, index: index, state: 'exit' }); } } /* OBSERVER - INTERSECT HANDLING */ // this is good for entering while scrolling down + leaving while scrolling up function intersectStepAbove(ref) { var entry = ref[0]; updateDirection(); var isIntersecting = entry.isIntersecting; var boundingClientRect = entry.boundingClientRect; var target = entry.target; // bottom = bottom edge of element from top of viewport // bottomAdjusted = bottom edge of element from trigger var top = boundingClientRect.top; var bottom = boundingClientRect.bottom; var topAdjusted = top - offsetMargin; var bottomAdjusted = bottom - offsetMargin; var index = getIndex(target); var ss = stepStates[index]; // entering above is only when topAdjusted is negative // and bottomAdjusted is positive if ( isIntersecting && topAdjusted <= 0 && bottomAdjusted >= 0 && direction === 'down' && ss.state !== 'enter' ) { notifyStepEnter(target, direction); } // exiting from above is when topAdjusted is positive and not intersecting if ( !isIntersecting && topAdjusted > 0 && direction === 'up' && ss.state === 'enter' ) { notifyStepExit(target, direction); } } // this is good for entering while scrolling up + leaving while scrolling down function intersectStepBelow(ref) { var entry = ref[0]; updateDirection(); var isIntersecting = entry.isIntersecting; var boundingClientRect = entry.boundingClientRect; var target = entry.target; // bottom = bottom edge of element from top of viewport // bottomAdjusted = bottom edge of element from trigger var top = boundingClientRect.top; var bottom = boundingClientRect.bottom; var topAdjusted = top - offsetMargin; var bottomAdjusted = bottom - offsetMargin; var index = getIndex(target); var ss = stepStates[index]; // entering below is only when bottomAdjusted is positive // and topAdjusted is negative if ( isIntersecting && topAdjusted <= 0 && bottomAdjusted >= 0 && direction === 'up' && ss.state !== 'enter' ) { notifyStepEnter(target, direction); } // exiting from above is when bottomAdjusted is negative and not intersecting if ( !isIntersecting && bottomAdjusted < 0 && direction === 'down' && ss.state === 'enter' ) { notifyStepExit(target, direction); } } /* if there is a scroll event where a step never intersects (therefore skipping an enter/exit trigger), use this fallback to detect if it is in view */ function intersectViewportAbove(ref) { var entry = ref[0]; updateDirection(); var isIntersecting = entry.isIntersecting; var target = entry.target; var index = getIndex(target); var ss = stepStates[index]; if ( isIntersecting && direction === 'down' && ss.direction !== 'down' && ss.state !== 'enter' ) { notifyStepEnter(target, 'down'); notifyStepExit(target, 'down'); } } function intersectViewportBelow(ref) { var entry = ref[0]; updateDirection(); var isIntersecting = entry.isIntersecting; var target = entry.target; var index = getIndex(target); var ss = stepStates[index]; if ( isIntersecting && direction === 'up' && ss.direction === 'down' && ss.state !== 'enter' ) { notifyStepEnter(target, 'up'); notifyStepExit(target, 'up'); } } function intersectStepProgress(ref) { var entry = ref[0]; updateDirection(); var isIntersecting = entry.isIntersecting; var intersectionRatio = entry.intersectionRatio; var boundingClientRect = entry.boundingClientRect; var target = entry.target; var bottom = boundingClientRect.bottom; var bottomAdjusted = bottom - offsetMargin; if (isIntersecting && bottomAdjusted >= 0) { notifyStepProgress(target, +intersectionRatio.toFixed(3)); } } /* OBSERVER - CREATION */ // jump into viewport function updateViewportAboveIO() { io.viewportAbove = stepEl.map(function (el, i) { var marginTop = pageH - stepOffsetTop[i]; var marginBottom = offsetMargin - viewH - stepOffsetHeight[i]; var rootMargin = marginTop + "px 0px " + marginBottom + "px 0px"; var options = { rootMargin: rootMargin }; // console.log(options); var obs = new IntersectionObserver(intersectViewportAbove, options); obs.observe(el); return obs; }); } function updateViewportBelowIO() { io.viewportBelow = stepEl.map(function (el, i) { var marginTop = -offsetMargin - stepOffsetHeight[i]; var marginBottom = offsetMargin - viewH + stepOffsetHeight[i] + pageH; var rootMargin = marginTop + "px 0px " + marginBottom + "px 0px"; var options = { rootMargin: rootMargin }; // console.log(options); var obs = new IntersectionObserver(intersectViewportBelow, options); obs.observe(el); return obs; }); } // look above for intersection function updateStepAboveIO() { io.stepAbove = stepEl.map(function (el, i) { var marginTop = -offsetMargin + stepOffsetHeight[i]; var marginBottom = offsetMargin - viewH; var rootMargin = marginTop + "px 0px " + marginBottom + "px 0px"; var options = { rootMargin: rootMargin }; // console.log(options); var obs = new IntersectionObserver(intersectStepAbove, options); obs.observe(el); return obs; }); } // look below for intersection function updateStepBelowIO() { io.stepBelow = stepEl.map(function (el, i) { var marginTop = -offsetMargin; var marginBottom = offsetMargin - viewH + stepOffsetHeight[i]; var rootMargin = marginTop + "px 0px " + marginBottom + "px 0px"; var options = { rootMargin: rootMargin }; // console.log(options); var obs = new IntersectionObserver(intersectStepBelow, options); obs.observe(el); return obs; }); } // progress progress tracker function updateStepProgressIO() { io.stepProgress = stepEl.map(function (el, i) { var marginTop = stepOffsetHeight[i] - offsetMargin; var marginBottom = -viewH + offsetMargin; var rootMargin = marginTop + "px 0px " + marginBottom + "px 0px"; var threshold = createThreshold(stepOffsetHeight[i]); var options = { rootMargin: rootMargin, threshold: threshold }; // console.log(options); var obs = new IntersectionObserver(intersectStepProgress, options); obs.observe(el); return obs; }); } function updateIO() { OBSERVER_NAMES.forEach(disconnectObserver); updateViewportAboveIO(); updateViewportBelowIO(); updateStepAboveIO(); updateStepBelowIO(); if (progressMode) { updateStepProgressIO(); } } /* SETUP FUNCTIONS */ function indexSteps() { stepEl.forEach(function (el, i) { return el.setAttribute('data-scrollama-index', i); }); } function setupStates() { stepStates = stepEl.map(function () { return ({ direction: null, state: null, progress: 0, }); }); } function addDebug() { if (isDebug) { setup({ id: id, stepEl: stepEl, offsetVal: offsetVal }); } } function isYScrollable(element) { var style = window.getComputedStyle(element); return ( (style.overflowY === 'scroll' || style.overflowY === 'auto') && element.scrollHeight > element.clientHeight ); } // recursively search the DOM for a parent container with overflowY: scroll and fixed height // ends at document function anyScrollableParent(element) { if (element && element.nodeType === 1) { // check dom elements only, stop at document // if a scrollable element is found return the element // if not continue to next parent return isYScrollable(element) ? element : anyScrollableParent(element.parentNode); } return false; // didn't find a scrollable parent } var S = {}; S.setup = function (ref) { var step = ref.step; var offset = ref.offset; if ( offset === void 0 ) offset = 0.5; var progress = ref.progress; if ( progress === void 0 ) progress = false; var threshold = ref.threshold; if ( threshold === void 0 ) threshold = 4; var debug = ref.debug; if ( debug === void 0 ) debug = false; var order = ref.order; if ( order === void 0 ) order = true; var once = ref.once; if ( once === void 0 ) once = false; // create id unique to this scrollama instance id = generateInstanceID(); stepEl = selectAll(step); if (!stepEl.length) { console.error('scrollama error: no step elements'); return S; } // ensure that no step has a scrollable parent element in the dom tree // check current step for scrollable parent // assume no scrollable parents to start var scrollableParent = stepEl.reduce( function (foundScrollable, s) { return foundScrollable || anyScrollableParent(s.parentNode); }, false ); if (scrollableParent) { console.error( 'scrollama error: step elements cannot be children of a scrollable element. Remove any css on the parent element with overflow: scroll; or overflow: auto; on elements with fixed height.', scrollableParent ); } // options isDebug = debug; progressMode = progress; preserveOrder = order; triggerOnce = once; S.offsetTrigger(offset); progressThreshold = Math.max(1, +threshold); isReady = true; // customize addDebug(); indexSteps(); setupStates(); handleResize(); S.enable(); return S; }; S.resize = function () { handleResize(); return S; }; S.enable = function () { handleEnable(true); return S; }; S.disable = function () { handleEnable(false); return S; }; S.destroy = function () { handleEnable(false); Object.keys(cb).forEach(function (c) { cb[c] = null; }); Object.keys(io).forEach(function (i) { io[i] = null; }); }; S.offsetTrigger = function (x) { if (x && !isNaN(x)) { if (x > 1) { console.error( 'scrollama error: offset value is greater than 1. Fallbacks to 1.' ); } if (x < 0) { console.error( 'scrollama error: offset value is lower than 0. Fallbacks to 0.' ); } offsetVal = Math.min(Math.max(0, x), 1); return S; } if (isNaN(x)) { console.error( 'scrollama error: offset value is not a number. Fallbacks to 0.' ); } return offsetVal; }; S.onStepEnter = function (f) { if (typeof f === 'function') { cb.stepEnter = f; } else { console.error('scrollama error: onStepEnter requires a function'); } return S; }; S.onStepExit = function (f) { if (typeof f === 'function') { cb.stepExit = f; } else { console.error('scrollama error: onStepExit requires a function'); } return S; }; S.onStepProgress = function (f) { if (typeof f === 'function') { cb.stepProgress = f; } else { console.error('scrollama error: onStepProgress requires a function'); } return S; }; return S; } return scrollama; })));