UNPKG

scrollama

Version:

Lightweight scrollytelling library using IntersectionObserver

417 lines (345 loc) 10.9 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = global || self, global.scrollama = factory()); }(this, (function () { 'use strict'; // DOM helper functions // public function selectAll(selector, parent = document) { if (typeof selector === 'string') { return Array.from(parent.querySelectorAll(selector)); } else if (selector instanceof Element) { return [selector]; } else if (selector instanceof NodeList) { return Array.from(selector); } else if (selector instanceof Array) { return selector; } return []; } // SETUP function create(className) { const el = document.createElement("div"); el.className = `scrollama__debug-step ${className}`; el.style.position = "fixed"; el.style.left = "0"; el.style.width = "100%"; el.style.zIndex = "9999"; el.style.borderTop = "2px solid black"; el.style.borderBottom = "2px solid black"; const p = document.createElement("p"); p.style.position = "absolute"; p.style.left = "0"; p.style.height = "1px"; p.style.width = "100%"; p.style.borderTop = "1px dashed black"; el.appendChild(p); document.body.appendChild(el); return el; } // UPDATE function update({ id, step, marginTop }) { const { index, height } = step; const className = `scrollama__debug-step--${id}-${index}`; let el = document.querySelector(`.${className}`); if (!el) el = create(className); el.style.top = `${marginTop * -1}px`; el.style.height = `${height}px`; el.querySelector("p").style.top = `${height / 2}px`; } function generateId() { const alphabet = "abcdefghijklmnopqrstuvwxyz"; const date = Date.now(); const result = []; for (let i = 0; i < 6; i += 1) { const char = alphabet[Math.floor(Math.random() * alphabet.length)]; result.push(char); } return `${result.join("")}${date}`; } function err$1(msg) { console.error(`scrollama error: ${msg}`); } function getIndex(node) { return +node.getAttribute("data-scrollama-index"); } function createProgressThreshold(height, threshold) { const count = Math.ceil(height / threshold); const t = []; const ratio = 1 / count; for (let i = 0; i < count + 1; i += 1) { t.push(i * ratio); } return t; } function parseOffset(x) { if (typeof x === "string" && x.indexOf("px") > 0) { const v = +x.replace("px", ""); if (!isNaN(v)) return { format: "pixels", value: v }; else { err("offset value must be in 'px' format. Fallback to 0.5."); return { format: "percent", value: 0.5 }; } } else if (typeof x === "number" || !isNaN(+x)) { if (x > 1) err("offset value is greater than 1. Fallback to 1."); if (x < 0) err("offset value is lower than 0. Fallback to 0."); return { format: "percent", value: Math.min(Math.max(0, x), 1) }; } return null; } function indexSteps(steps) { steps.forEach((step) => step.node.setAttribute("data-scrollama-index", step.index) ); } function getOffsetTop(node) { const { top } = node.getBoundingClientRect(); const scrollTop = window.pageYOffset; const clientTop = document.body.clientTop || 0; return top + scrollTop - clientTop; } let currentScrollY; let comparisonScrollY; let direction; function onScroll(container) { const scrollTop = container ? container.scrollTop : window.pageYOffset; if (currentScrollY === scrollTop) return; currentScrollY = scrollTop; if (currentScrollY > comparisonScrollY) direction = "down"; else if (currentScrollY < comparisonScrollY) direction = "up"; comparisonScrollY = currentScrollY; } function setupScroll(container) { currentScrollY = 0; comparisonScrollY = 0; document.addEventListener("scroll", () => onScroll(container)); } function scrollama() { let cb = {}; let id = generateId(); let steps = []; let globalOffset; let containerElement; let rootElement; let progressThreshold = 0; let isEnabled = false; let isProgress = false; let isDebug = false; let isTriggerOnce = false; let exclude = []; /* HELPERS */ function reset() { cb = { stepEnter: () => { }, stepExit: () => { }, stepProgress: () => { }, }; exclude = []; } function handleEnable(shouldEnable) { if (shouldEnable && !isEnabled) updateObservers(); if (!shouldEnable && isEnabled) disconnectObservers(); isEnabled = shouldEnable; } /* NOTIFY CALLBACKS */ function notifyProgress(element, progress) { const index = getIndex(element); const step = steps[index]; if (progress !== undefined) step.progress = progress; const response = { element, index, progress, direction }; if (step.state === "enter") cb.stepProgress(response); } function notifyStepEnter(element, check = true) { const index = getIndex(element); const step = steps[index]; const response = { element, index, direction }; step.direction = direction; step.state = "enter"; // if (isPreserveOrder && check && direction !== "up") // notifyOthers(index, "above"); // if (isPreserveOrder && check && direction === "up") // notifyOthers(index, "below"); if (!exclude[index]) cb.stepEnter(response); if (isTriggerOnce) exclude[index] = true; } function notifyStepExit(element, check = true) { const index = getIndex(element); const step = steps[index]; if (!step.state) return false; const response = { element, index, direction }; if (isProgress) { if (direction === "down" && step.progress < 1) notifyProgress(element, 1); else if (direction === "up" && step.progress > 0) notifyProgress(element, 0); } step.direction = direction; step.state = "exit"; cb.stepExit(response); } /* OBSERVERS - HANDLING */ function resizeStep([entry]) { const index = getIndex(entry.target); const step = steps[index]; const h = entry.target.offsetHeight; if (h !== step.height) { step.height = h; disconnectObserver(step); updateStepObserver(step); updateResizeObserver(step); } } function intersectStep([entry]) { onScroll(containerElement); const { isIntersecting, target } = entry; if (isIntersecting) notifyStepEnter(target); else notifyStepExit(target); } function intersectProgress([entry]) { const index = getIndex(entry.target); const step = steps[index]; const { isIntersecting, intersectionRatio, target } = entry; if (isIntersecting && step.state === "enter") notifyProgress(target, intersectionRatio); } /* OBSERVERS - CREATION */ function disconnectObserver({ observers }) { Object.keys(observers).map((name) => { observers[name].disconnect(); }); } function disconnectObservers() { steps.forEach(disconnectObserver); } function updateResizeObserver(step) { const observer = new ResizeObserver(resizeStep); observer.observe(step.node); step.observers.resize = observer; } function updateResizeObservers() { steps.forEach(updateResizeObserver); } function updateStepObserver(step) { const h = window.innerHeight; const off = step.offset || globalOffset; const factor = off.format === "pixels" ? 1 : h; const offset = off.value * factor; const marginTop = step.height / 2 - offset; const marginBottom = step.height / 2 - (h - offset); const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const root = rootElement; const threshold = 0.5; const options = { rootMargin, threshold, root }; const observer = new IntersectionObserver(intersectStep, options); observer.observe(step.node); step.observers.step = observer; if (isDebug) update({ id, step, marginTop, marginBottom }); } function updateStepObservers() { steps.forEach(updateStepObserver); } function updateProgressObserver(step) { const h = window.innerHeight; const off = step.offset || globalOffset; const factor = off.format === "pixels" ? 1 : h; const offset = off.value * factor; const marginTop = -offset + step.height; const marginBottom = offset - h; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const threshold = createProgressThreshold(step.height, progressThreshold); const options = { rootMargin, threshold }; const observer = new IntersectionObserver(intersectProgress, options); observer.observe(step.node); step.observers.progress = observer; } function updateProgressObservers() { steps.forEach(updateProgressObserver); } function updateObservers() { disconnectObservers(); updateResizeObservers(); updateStepObservers(); if (isProgress) updateProgressObservers(); } /* SETUP */ const S = {}; S.setup = ({ step, parent, offset = 0.5, threshold = 4, progress = false, once = false, debug = false, container = undefined, root = null }) => { setupScroll(container); steps = selectAll(step, parent).map((node, index) => ({ index, direction: undefined, height: node.offsetHeight, node, observers: {}, offset: parseOffset(node.dataset.offset), top: getOffsetTop(node), progress: 0, state: undefined, })); if (!steps.length) { err$1("no step elements"); return S; } isProgress = progress; isTriggerOnce = once; isDebug = debug; progressThreshold = Math.max(1, +threshold); globalOffset = parseOffset(offset); containerElement = container; rootElement = root; reset(); indexSteps(steps); handleEnable(true); return S; }; S.enable = () => { handleEnable(true); return S; }; S.disable = () => { handleEnable(false); return S; }; S.destroy = () => { handleEnable(false); reset(); return S; }; S.resize = () => { updateObservers(); return S; }; S.offset = (x) => { if (x === null || x === undefined) return globalOffset.value; globalOffset = parseOffset(x); updateObservers(); return S; }; S.onStepEnter = (f) => { if (typeof f === "function") cb.stepEnter = f; else err$1("onStepEnter requires a function"); return S; }; S.onStepExit = (f) => { if (typeof f === "function") cb.stepExit = f; else err$1("onStepExit requires a function"); return S; }; S.onStepProgress = (f) => { if (typeof f === "function") cb.stepProgress = f; else err$1("onStepProgress requires a function"); return S; }; return S; } return scrollama; })));