From e897e150254795d98110733b729517bfc51a0348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gonz=C3=A1lez?= Date: Thu, 21 May 2020 22:25:43 +0100 Subject: [PATCH 1/3] Add file --- scrollstory.js | 1666 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1666 insertions(+) create mode 100644 scrollstory.js diff --git a/scrollstory.js b/scrollstory.js new file mode 100644 index 0000000..cb9bad5 --- /dev/null +++ b/scrollstory.js @@ -0,0 +1,1666 @@ +/** + * @preserve ScrollStory - vVERSIONXXX - YYYY-MM-DDXXX + * https://github.com/sjwilliams/scrollstory + * Copyright (c) 2017 Josh Williams; Licensed MIT + */ + +function scrollStory(options) { + var pluginName = "scrollStory"; + var eventNameSpace = "." + pluginName; + var defaults = { + // selector or node where we launch scrollStory + container: null, + + // jquery object, class selector string, or array of values, or null (to use existing DOM) + content: null, + + // Only used if content null. Should be a class selector + contentSelector: ".story", + + // Left/right keys to navigate + keyboard: true, + + // Offset from top used in the programatic scrolling of an + // item to the focus position. Useful in the case of thinks like + // top nav that might obscure part of an item if it goes to 0. + scrollOffset: 0, + + // Offset from top to trigger a change + triggerOffset: 0, + + // Event to monitor. Can be a name for an event on the $(window), or + // a function that defines custom behavior. Defaults to native scroll event. + scrollEvent: "scroll", + + // Automatically activate the first item on load, + // regardless of its position relative to the offset + autoActivateFirstItem: false, + + // Disable last item -- and the entire widget -- once it's scroll beyond the trigger point + disablePastLastItem: true, + + // Automated scroll speed in ms. Set to 0 to remove animation. + speed: 800, + + // Scroll easing. 'swing' or 'linear', unless an external plugin provides others + // http://api.jquery.com/animate/ + easing: "swing", + + // // scroll-based events are either 'debounce' or 'throttle' + throttleType: "throttle", + + // frequency in milliseconds to perform scroll-based functions. Scrolling functions + // can be CPU intense, so higher number can help performance. + scrollSensitivity: 100, + + // options to pass to underscore's throttle or debounce for scroll + // see: http://underscorejs.org/#throttle && http://underscorejs.org/#debounce + throttleTypeOptions: null, + + // Update offsets after likely repaints, like window resizes and filters + autoUpdateOffsets: true, + + debug: false, + + // whether or not the scroll checking is enabled. + enabled: true, + + setup: function () {}, + destroy: function () {}, + itembuild: function () {}, + itemfocus: function () {}, + itemblur: function () {}, + itemfilter: function () {}, + itemunfilter: function () {}, + itementerviewport: function () {}, + itemexitviewport: function () {}, + categoryfocus: function () {}, + categeryblur: function () {}, + containeractive: function () {}, + containerinactive: function () {}, + containerresize: function () {}, + containerscroll: function () {}, + updateoffsets: function () {}, + triggeroffsetupdate: function () {}, + scrolloffsetupdate: function () {}, + complete: function () {}, + }; + + // static across all plugin instances + // so we can uniquely ID elements + var instanceCounter = 0; + + /** + * Utility methods + * + * debounce() and throttle() are from on Underscore.js: + * https://github.com/jashkenas/underscore + */ + + /** + * Underscore's now: + * http://underscorejs.org/now + */ + var dateNow = + Date.now || + function () { + return new Date().getTime(); + }; + + /** + * Underscore's debounce: + * http://underscorejs.org/#debounce + */ + var debounce = function (func, wait, immediate) { + var result; + var timeout = null; + return function () { + var context = this, + args = arguments; + var later = function () { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }; + + /** + * Underscore's throttle: + * http://underscorejs.org/#throttle + */ + var throttle = function (func, wait, options) { + var context, args, result; + var timeout = null; + var previous = 0; + options || (options = {}); + var later = function () { + previous = options.leading === false ? 0 : dateNow(); + timeout = null; + result = func.apply(context, args); + }; + return function () { + var now = dateNow(); + if (!previous && options.leading === false) { + 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 && options.trailing !== false) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }; + + /** + * Simpler isPlainObject + * this probably needs lodash… + * https://stackoverflow.com/questions/18531624/isplainobject-thing + */ + var isPlainObject = function (obj) { + return Object.prototype.toString.call(obj) === "[object Object]"; + }; + + /** + * Vanilla isFunction + */ + var isFunction = function (variable) { + return typeof variable === "function"; + }; + + /** + * Deep extend + * equivalent to $.extend(true, {}, objA, objB); + * http://youmightnotneedjquery.com/ + */ + var deepExtend = function (out) { + out = out || {}; + + for (var i = 1; i < arguments.length; i++) { + var obj = arguments[i]; + + if (!obj) continue; + + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === "object") { + if (obj[key] instanceof Array == true) out[key] = obj[key].slice(0); + else out[key] = deepExtend(out[key], obj[key]); + } else out[key] = obj[key]; + } + } + } + + return out; + }; + + /** + * Event trigger + * equivalent to $.trigger + * http://youmightnotneedjquery.com/ + */ + var trigger = function (el, event, data) { + if (window.CustomEvent && typeof window.CustomEvent === "function") { + var triggerEvent = new CustomEvent(event, { detail: data }); + } else { + var triggerEvent = document.createEvent("CustomEvent"); + triggerEvent.initCustomEvent(event, true, true, data); + } + + el.dispatchEvent(triggerEvent); + }; + + var winHeight = window.innerHeight; // cached. updated via _handleResize() + + /** + * Given a scroll/trigger offset, determine + * its pixel value from the top of the viewport. + * + * If number or number-like string (30 or '30'), return that + * number. (30) + * + * If it's a percentage string ('30%'), convert to pixels + * based on the height of the viewport. (eg: 395) + * + * @param {String/Number} offset + * @return {Number} + */ + var offsetToPx = function (offset) { + var pxOffset; + + if (offsetIsAPercentage(offset)) { + pxOffset = offset.slice(0, -1); + pxOffset = Math.round(winHeight * (parseInt(pxOffset, 10) / 100)); + } else { + pxOffset = parseInt(offset, 10); + } + + return pxOffset; + }; + + var offsetIsAPercentage = function (offset) { + return typeof offset === "string" && offset.slice(-1) === "%"; + }; + + // select a dom element + // based on https://github.com/russellgoldenberg/scrollama/blob/master/src/dom.js + var select = function (selector, parent = document) { + if (typeof selector === "string") { + return parent.querySelector(selector); + } else if (selector instanceof Element) { + return selector; + } + + return null; + }; + + // select all dom elements + // based on https://github.com/russellgoldenberg/scrollama/blob/master/src/dom.js + var selectAll = function (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 []; + }; + + function ScrollStory(options) { + this.options = Object.assign({}, defaults, options); + this.el = select(this.options.container); + + this.useNativeScroll = + typeof this.options.scrollEvent === "string" && + this.options.scrollEvent.indexOf("scroll") === 0; + + this._defaults = defaults; + this._name = pluginName; + this._instanceId = (function () { + return pluginName + "_" + instanceCounter; + })(); + + this.init(); + } + + ScrollStory.prototype = { + init: function () { + /** + * List of all items, and a quick lockup hash + * Data populated via _prepItems* methods + */ + this._items = []; + this._itemsById = {}; + this._categories = []; + this._tags = []; + + this._isActive = false; + this._activeItem; + this._previousItems = []; + + /** + * Attach handlers before any events are dispatched + */ + this.el.addEventListener("setup", this._onSetup.bind(this)); + this.el.addEventListener("destroy", this._onDestroy.bind(this)); + this.el.addEventListener( + "containeractive", + this._onContainerActive.bind(this) + ); + this.el.addEventListener( + "containerinactive", + this._onContainerInactive.bind(this) + ); + this.el.addEventListener("itemblur", this._onItemBlur.bind(this)); + this.el.addEventListener("itemfocus", this._onItemFocus.bind(this)); + this.el.addEventListener( + "itementerviewport", + this._onItemEnterViewport.bind(this) + ); + this.el.addEventListener( + "itemexitviewport", + this._onItemExitViewport.bind(this) + ); + this.el.addEventListener("itemfilter", this._onItemFilter.bind(this)); + this.el.addEventListener("itemunfilter", this._onItemUnfilter.bind(this)); + this.el.addEventListener( + "categoryfocus", + this._onCategoryFocus.bind(this) + ); + this.el.addEventListener( + "triggeroffsetupdate", + this._onTriggerOffsetUpdate.bind(this) + ); + + /** + * Run before any items have been added, allows + * for user manipulation of page before ScrollStory + * acts on anything. + */ + this._trigger("setup", null, this); + + /** + * Convert data from outside of widget into + * items and, if needed, categories of items. + * + * Don't 'handleRepaints' just yet, as that'll + * set an active item. We want to do that after + * our 'complete' event is triggered. + */ + this.addItems(this.options.content, { + handleRepaint: false, + }); + + // 1. offsets need to be accurate before 'complete' + this.updateOffsets(); + + // 2. handle any user actions + this._trigger("complete", null, this); + + // 3. Set active item, and double check + // scroll position and offsets. + if (this.options.enabled) { + this._handleRepaint(); + } + + /** + * Bind keyboard events + */ + if (this.options.keyboard) { + document.addEventListener( + "keydown", + function (e) { + var captured = true; + switch (e.keyCode) { + case 37: + if (e.metaKey) { + return; + } // ignore ctrl/cmd left, as browsers use that to go back in history + this.previous(); + break; // left arrow + case 39: + this.next(); + break; // right arrow + default: + captured = false; + } + return !captured; + }.bind(this) + ); + } + + /** + * Debug UI + */ + this.$trigger = document.createElement("div"); + this.$trigger.className = pluginName + "Trigger"; + this.$trigger.id = pluginName + "Trigger-" + this._instanceId; + + this.$trigger.style.position = "fixed"; + this.$trigger.style.width = "100%"; + this.$trigger.style.height = "1px"; + this.$trigger.style.top = offsetToPx(this.options.triggerOffset) + "px"; + this.$trigger.style.left = "0px"; + this.$trigger.style.backgroundColor = "#ff0000"; + this.$trigger.style.transform = "translateZ(0)"; + this.$trigger.style.WebkitBackfaceVisibility = "hidden"; + this.$trigger.style.zIndex = 100; + + if (this.options.debug) { + document.body.appendChild(this.$trigger); + } + + /** + * Watch either native scroll events, throttled by + * this.options.scrollSensitivity, or a custom event + * that implements its own throttling. + * + * Bind these events after 'complete' trigger so no + * items are active when those callbacks runs. + */ + + var scrollThrottle, scrollHandler; + + if (this.useNativeScroll) { + // bind and throttle native scroll + scrollThrottle = + this.options.throttleType === "throttle" ? throttle : debounce; + scrollHandler = scrollThrottle( + this._handleScroll.bind(this), + this.options.scrollSensitivity, + this.options.throttleTypeOptions + ); + window.addEventListener("scroll", scrollHandler); + } else { + // bind but don't throttle custom event + scrollHandler = this._handleScroll.bind(this); + + // if custom event is a function, it'll need + // to call the scroll handler manually, like so: + // + // $container.scrollStory({ + // scrollEvent: function(cb){ + // // custom scroll event on nytimes.com + // PageManager.on('nyt:page-scroll', function(){ + // // do something interesting if you like + // // and then call the passed in handler(); + // cb(); + // }); + // } + // }); + // + // + // Otherwise, it's a string representing an event on the + // window to subscribe to, like so: + // + // // some code dispatching throttled events + // $window.trigger('nytg-scroll'); + // + // $container.scrollStory({ + // scrollEvent: 'nytg-scroll' + // }); + // + + if (typeof this.options.scrollEvent === "function") { + this.options.scrollEvent(scrollHandler); + } else { + window.addEventListener(this.options.scrollEvent, function () { + scrollHandler(); + }); + } + } + + // anything that might cause a repaint + var resizeThrottle = debounce(this._handleResize, 100); + window.addEventListener("DOMContentLoaded", resizeThrottle.bind(this)); + window.addEventListener("load", resizeThrottle.bind(this)); + window.addEventListener("resize", resizeThrottle.bind(this)); + + instanceCounter = instanceCounter + 1; + }, + + /** + * Get current item's index, + * or set the current item with an index. + * @param {Number} index + * @param {Function} callback + * @return {Number} index of active item + */ + index: function (index, callback) { + if (typeof index === "number" && this.getItemByIndex(index)) { + this.setActiveItem(this.getItemByIndex(index), {}, callback); + } else { + return this.getActiveItem().index; + } + }, + + /** + * Convenience method to navigate to next item + * + * @param {Number} _index -- an optional index. Used to recursively find unflitered item + */ + next: function (_index) { + var currentIndex = _index || this.index(); + var nextItem; + + if (typeof currentIndex === "number") { + nextItem = this.getItemByIndex(currentIndex + 1); + + // valid index and item + if (nextItem) { + // proceed if not filtered. if filtered try the one after that. + if (!nextItem.filtered) { + this.index(currentIndex + 1); + } else { + this.next(currentIndex + 1); + } + } + } + }, + + /** + * Convenience method to navigate to previous item + * + * @param {Number} _index -- an optional index. Used to recursively find unflitered item + */ + previous: function (_index) { + var currentIndex = _index || this.index(); + var previousItem; + + if (typeof currentIndex === "number") { + previousItem = this.getItemByIndex(currentIndex - 1); + + // valid index and item + if (previousItem) { + // proceed if not filtered. if filtered try the one before that. + if (!previousItem.filtered) { + this.index(currentIndex - 1); + } else { + this.previous(currentIndex - 1); + } + } + } + }, + + /** + * The active item object. + * + * @return {Object} + */ + getActiveItem: function () { + return this._activeItem; + }, + + /** + * Given an item object, make it active, + * including updating its scroll position. + * + * @param {Object} item + */ + setActiveItem: function (item, options, callback) { + options = options || {}; + + // verify item + if (item.id && this.getItemById(item.id)) { + this._scrollToItem(item, options, callback); + } + }, + + /** + * Iterate over each item, passing the item to a callback. + * + * this.each(function(item){ console.log(item.id) }); + * + * @param {Function} + */ + each: function (callback) { + this.applyToAllItems(callback); + }, + + /** + * Return number of items + * @return {Number} + */ + getLength: function () { + return this.getItems().length; + }, + + /** + * Return array of all items + * @return {Array} + */ + getItems: function () { + return this._items; + }, + + /** + * Given an item id, return item object with that id. + * + * @param {string} id + * @return {Object} + */ + getItemById: function (id) { + return this._itemsById[id]; + }, + + /** + * Given an item index, return item object with that index. + * + * @param {Integer} index + * @return {Object} + */ + getItemByIndex: function (index) { + return this._items[index]; + }, + + /** + * Return an array of items that pass an abritrary truth test. + * + * Example: this.getItemsBy(function(item){return item.data.slug=='josh_williams'}) + * + * @param {Function} truthTest The function to check all items against + * @return {Array} Array of item objects + */ + getItemsBy: function (truthTest) { + if (typeof truthTest !== "function") { + throw new Error("You must provide a truthTest function"); + } + + return this.getItems().filter(function (item) { + return truthTest(item); + }); + }, + + /** + * Returns an array of items where all the properties + * match an item's properties. Property tests can be + * any combination of: + * + * 1. Values + * this.getItemsWhere({index:2}); + * this.getItemsWhere({filtered:false}); + * this.getItemsWhere({category:'cats', width: 300}); + * + * 2. Methods that return a value + * this.getItemsWhere({width: function(width){ return 216 + 300;}}); + * + * 3. Methods that return a boolean + * this.getItemsWhere({index: function(index){ return index > 2; } }); + * + * Mix and match: + * this.getItemsWehre({filtered:false, index: function(index){ return index < 30;} }) + * + * @param {Object} properties + * @return {Array} Array of item objects + */ + getItemsWhere: function (properties) { + var keys, + items = []; // empty if properties obj not passed in + + if (isPlainObject(properties)) { + keys = Object.keys(properties); // properties to check in each item + items = this.getItemsBy(function (item) { + var isMatch = keys.every(function (key) { + var match; + + // type 3, method that runs a boolean + if (typeof properties[key] === "function") { + match = properties[key](item[key]); + + // type 2, method that runs a value + if (typeof match !== "boolean") { + match = item[key] === match; + } + } else { + // type 1, value + match = item[key] === properties[key]; + } + return match; + }); + + if (isMatch) { + return item; + } + }); + } + + return items; + }, + + /** + * Array of items that are atleast partially visible + * + * @return {Array} + */ + getItemsInViewport: function () { + return this.getItemsWhere({ + inViewport: true, + }); + }, + + /** + * Most recently active item. + * + * @return {Object} + */ + getPreviousItem: function () { + return this._previousItems[0]; + }, + + /** + * active, with most recently active + * Array of items that were previously + * at the front of the array. + * + * @return {Array} + */ + getPreviousItems: function () { + return this._previousItems; + }, + + /** + * Progress of the scroll needed to activate the + * last item on a 0.0 - 1.0 scale. + * + * 0 means the first item isn't yet active, + * and 1 means the last item is active, or + * has already been scrolled beyond active. + * + * @return {[type]} [description] + */ + getPercentScrollToLastItem: function () { + return this._percentScrollToLastItem || 0; + }, + + /** + * Progress of the entire scroll distance, from the start + * of the first item a '0', until the very end of the last + * item, which is '1'; + */ + getScrollComplete: function () { + return this._totalScrollComplete || 0; + }, + + /** + * Return an array of all filtered items. + * @return {Array} + */ + getFilteredItems: function () { + return this.getItemsWhere({ + filtered: true, + }); + }, + + /** + * Return an array of all unfiltered items. + * @return {Array} + */ + getUnFilteredItems: function () { + return this.getItemsWhere({ + filtered: false, + }); + }, + + /** + * Return an array of all items belonging to a category. + * + * @param {String} categorySlug + * @return {Array} + */ + getItemsByCategory: function (categorySlug) { + return this.getItemsWhere({ + category: categorySlug, + }); + }, + + /** + * Return an array of all category slugs + * + * @return {Array} + */ + getCategorySlugs: function () { + return this._categories; + }, + + /** + * Change an item's status to filtered. + * + * @param {Object} item + */ + filter: function (item) { + if (!item.filtered) { + item.filtered = true; + this._trigger("itemfilter", null, item); + } + }, + + /** + * Change an item's status to unfiltered. + * + * @param {Object} item + */ + unfilter: function (item) { + if (item.filtered) { + item.filtered = false; + this._trigger("itemunfilter", null, item); + } + }, + + /** + * Change all items' status to filtered. + * + * @param {Function} callback + */ + filterAll: function (callback) { + callback = isFunction(callback) ? callback.bind(this) : function () {}; + var filterFnc = this.filter.bind(this); + this.getItems().forEach(filterFnc); + }, + + /** + * Change all items' status to unfiltered. + * + * @param {Function} callback + */ + unfilterAll: function (callback) { + callback = isFunction(callback) ? callback.bind(this) : function () {}; + var unfilterFnc = this.unfilter.bind(this); + this.getItems().forEach(unfilterFnc); + }, + + /** + * Filter items that pass an abritrary truth test. This is a light + * wrapper around `getItemsBy()` and `filter()`. + * + * Example: this.filterBy(function(item){return item.data.last_name === 'williams'}) + * + * @param {Function} truthTest The function to check all items against + * @param {Function} callback + */ + filterBy: function (truthTest, callback) { + callback = isFunction(callback) ? callback.bind(this) : function () {}; + var filterFnc = this.filter.bind(this); + this.getItemsBy(truthTest).forEach(filterFnc); + callback(); + }, + + /** + * Filter items where all the properties match an item's properties. This + * is a light wrapper around `getItemsWhere()` and `filter()`. See `getItemsWhere()` + * for more options and examples. + * + * Example: this.filterWhere({index:2}) + * + * @param {Function} truthTest The function to check all items against + * @param {Function} callback + */ + filterWhere: function (properties, callback) { + callback = isFunction(callback) ? callback.bind(this) : function () {}; + var filterFnc = this.filter.bind(this); + this.getItemsWhere(properties).forEach(filterFnc); + callback(); + }, + + /** + * Whether or not any of the item objects are active. + * + * @return {Boolean} + */ + isContainerActive: function () { + return this._isActive; + }, + + /** + * Disable scroll updates. This is useful in the + * rare case when you want to manipulate the page + * but not have ScrollStory continue to check + * positions, fire events, etc. Usually a `disable` + * is temporary and followed by an `enable`. + */ + disable: function () { + this.options.enabled = false; + }, + + /** + * Enable scroll updates + */ + enable: function () { + this.options.enabled = true; + }, + + /** + * Update trigger offset. This is useful if a client + * app needs to, post-instantiation, change the trigger + * point, like after a window resize. + * + * @param {Number} offset + */ + updateTriggerOffset: function (offset) { + this.options.triggerOffset = offset; + this.updateOffsets(); + this._trigger("triggeroffsetupdate", null, offsetToPx(offset)); + }, + + /** + * Update scroll offset. This is useful if a client + * app needs to, post-instantiation, change the scroll + * offset, like after a window resize. + * @param {Number} offset + */ + updateScrollOffset: function (offset) { + this.options.scrollOffset = offset; + this.updateOffsets(); + this._trigger("scrolloffsetupdate", null, offsetToPx(offset)); + }, + + /** + * Determine which item should be active, + * and then make it so. + */ + _setActiveItem: function () { + // top of the container is above the trigger point and the bottom is still below trigger point. + var containerInActiveArea = + this._distanceToFirstItemTopOffset <= 0 && + Math.abs(this._distanceToOffset) - this._height < 0; + + // only check items that aren't filtered + var items = this.getItemsWhere({ + filtered: false, + }); + + var activeItem; + items.forEach(function (item) { + // item has to have crossed the trigger offset + if (item.adjustedDistanceToOffset <= 0) { + if (!activeItem) { + activeItem = item; + } else { + // closer to trigger point than previously found item? + if ( + activeItem.adjustedDistanceToOffset < + item.adjustedDistanceToOffset + ) { + activeItem = item; + } + } + } + }); + + // double check conditions around an active item + if ( + activeItem && + !containerInActiveArea && + this.options.disablePastLastItem + ) { + activeItem = false; + + // not yet scrolled in, but auto-activate is set to true + } else if ( + !activeItem && + this.options.autoActivateFirstItem && + items.length > 0 + ) { + activeItem = items[0]; + } + + if (activeItem) { + this._focusItem(activeItem); + + // container + if (!this._isActive) { + this._isActive = true; + this._trigger("containeractive"); + } + } else { + this._blurAllItems(); + + // container + if (this._isActive) { + this._isActive = false; + this._trigger("containerinactive"); + } + } + }, + + /** + * Scroll to an item, making it active. + * + * @param {Object} item + * @param {Object} opts + * @param {Function} callback + */ + _scrollToItem: function (item, opts, callback) { + callback = isFunction(callback) ? callback.bind(this) : function () {}; + + /** + * Allows global scroll options to be overridden + * in one of two ways: + * + * 1. Higher priority: Passed in to scrollToItem directly via opts obj. + * 2. Lower priority: options set as an item.* property + */ + // VANILLA_FIXME: remove speed and easing? + opts = deepExtend( + { + // prefer item.scrollOffset over this.options.scrollOffset + scrollOffset: + item.scrollOffset !== false + ? offsetToPx(item.scrollOffset) + : offsetToPx(this.options.scrollOffset), + speed: this.options.speed, + easing: this.options.easing, + }, + opts + ); + + // because we animate to body and html for maximum compatiblity, + // we only want the callback to fire once. jQuery will call it + // once for each element otherwise + var debouncedCallback = debounce(callback, 100); + + // position to travel to + // VANILLA_FIXME: enable/disable behaviour smooth? + var offsetTop = item.el.getBoundingClientRect().top + window.scrollY; + var scrolllTop = offsetTop - offsetToPx(opts.scrollOffset); + window.scrollTo({ top: scrolllTop, left: 0, behavior: "smooth" }); + + // VANILLA_FIXME: there's no easy way to do a callback here :/ + // maybe with intersection observer? + // https://stackoverflow.com/questions/46795955/how-to-know-scroll-to-element-is-done-in-javascript + window.setTimeout(debouncedCallback, 100); + }, + + /** + * Excecute a callback function that expects an + * item as its paramamter for each items. + * + * Optionally, a item or array of items of exceptions + * can be passed in. They'll not call the callback. + * + * @param {Function} callback Method to call, and pass in exepctions + * @param {Object/Array} exceptions + */ + applyToAllItems: function (callback, exceptions) { + exceptions = Array.isArray(exceptions) ? exceptions : [exceptions]; + callback = isFunction(callback) ? callback.bind(this) : function () {}; + + var items = this.getItems(); + var i = 0; + var length = items.length; + var item; + + for (i = 0; i < length; i++) { + item = items[i]; + if (exceptions.indexOf(item) === -1) { + callback(item, i); + } + } + }, + + /** + * Unfocus all items. + * + * @param {Object/Array} exceptions item or array of items to not blur + */ + _blurAllItems: function (exceptions) { + this.applyToAllItems(this._blurItem.bind(this), exceptions); + + if (!exceptions) { + this._activeItem = undefined; + } + }, + + /** + * Unfocus an item + * @param {Object} + */ + _blurItem: function (item) { + if (item.active) { + item.active = false; + this._trigger("itemblur", null, item); + } + }, + + /** + * Given an item, give it focus. Focus is exclusive + * so we unfocus any other item. + * + * @param {Object} item object + */ + _focusItem: function (item) { + if (!item.active && !item.filtered) { + this._blurAllItems(item); + + // make active + this._activeItem = item; + item.active = true; + + // notify clients of changes + this._trigger("itemfocus", null, item); + } + }, + + /** + * Iterate through items and update their top offset. + * Useful if items have been added, removed, + * repositioned externally, and after window resize + * + * Based on: + * http://javascript.info/tutorial/coordinates + * http://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433 + */ + updateOffsets: function () { + var bodyElem = document.body; + var docElem = document.documentElement; + + var scrollTop = + window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop; + var clientTop = docElem.clientTop || bodyElem.clientTop || 0; + var items = this.getItems(); + var i = 0; + var length = items.length; + var item; + var box; + + // individual items + for (i = 0; i < length; i++) { + item = items[i]; + box = item.el.getBoundingClientRect(); + + // add or update item properties + item.width = box.width; + item.height = box.height; + item.topOffset = box.top + scrollTop - clientTop; + } + + // container + box = this.el.getBoundingClientRect(); + this._height = box.height; + this._width = box.width; + this._topOffset = box.top + scrollTop - clientTop; + + this._trigger("updateoffsets"); + }, + + _updateScrollPositions: function () { + var bodyElem = document.body; + var docElem = document.documentElement; + var scrollTop = + window.pageYOffset || docElem.scrollTop || bodyElem.scrollTop; + var wHeight = window.innerHeight || docElem.clientHeight; + var wWidth = window.innerWidth || docElem.clientWidth; + var triggerOffset = offsetToPx(this.options.triggerOffset); + + // update item scroll positions + var items = this.getItems(); + var length = items.length; + var lastItem = items[length - 1]; + var i = 0; + var item; + var rect; + var previouslyInViewport; + + // track total scroll across all items + var totalScrollComplete = 0; + + for (i = 0; i < length; i++) { + item = items[i]; + rect = item.el.getBoundingClientRect(); + item.distanceToOffset = Math.floor( + item.topOffset - scrollTop - triggerOffset + ); // floor to prevent some off-by-fractional px in determining active item + item.adjustedDistanceToOffset = + item.triggerOffset === false + ? item.distanceToOffset + : item.topOffset - scrollTop - item.triggerOffset; + + // percent through this item's active scroll. expressed 0 - 1; + if (item.distanceToOffset >= 0) { + item.percentScrollComplete = 0; + } else if (Math.abs(item.distanceToOffset) >= rect.height) { + item.percentScrollComplete = 1; + } else { + item.percentScrollComplete = + Math.abs(item.distanceToOffset) / rect.height; + } + + // track percent scroll + totalScrollComplete = totalScrollComplete + item.percentScrollComplete; + + // track viewport status + previouslyInViewport = item.inViewport; + item.inViewport = + rect.bottom > 0 && + rect.right > 0 && + rect.left < wWidth && + rect.top < wHeight; + item.fullyInViewport = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= wHeight && + rect.right <= wWidth; + + if (item.inViewport && !previouslyInViewport) { + this._trigger("itementerviewport", null, item); + } else if (!item.inViewport && previouslyInViewport) { + this._trigger("itemexitviewport", null, item); + } + } + + // update container scroll position + this._distanceToFirstItemTopOffset = items[0].adjustedDistanceToOffset; + + // takes into account other elements that might make the top of the + // container different than the topoffset of the first item. + this._distanceToOffset = this._topOffset - scrollTop - triggerOffset; + + // percent of the total scroll needed to activate the last item + var percentScrollToLastItem = 0; + if (this._distanceToOffset < 0) { + percentScrollToLastItem = + 1 - lastItem.distanceToOffset / (this._height - lastItem.height); + percentScrollToLastItem = + percentScrollToLastItem < 1 ? percentScrollToLastItem : 1; // restrict range + } + + this._percentScrollToLastItem = percentScrollToLastItem; + + this._totalScrollComplete = totalScrollComplete / length; + }, + + /** + * Add items to the running list given any of the + * following inputs: + * + * 1. jQuery selection. Items will be generated + * from the selection, and any data-* attributes + * will be added to the item's data object. + * + * 2. A string selector to search for elements + * within our container. Items will be generated + * from that selection, and any data-* attributes + * will be added to the item's data object. + * + * 3. Array of objects. All needed markup will + * be generated, and the data in each object will + * be added to the item's data object. + * + * 4. If no 'items' param, we search for items + * using the options.contentSelector string. + * + * + * TODO: ensure existing items aren't re-added. + * This is expecially important for the empty items + * option, and will give us the ability to do + * infinite scrolls, etc. + * + * @param {jQuery Object/String/Array} items + */ + addItems: function (items, opts) { + opts = deepExtend({ handleRepaint: true }, opts); + + if (typeof items === "string") { + this._prepItemsFromSelection(this.el.querySelectorAll(items)); + + // array objects, which will be used to create markup + } else if (Array.isArray(items)) { + this._prepItemsFromData(items); + + // search for elements with the default selector + } else { + this._prepItemsFromSelection( + this.el.querySelectorAll(this.options.contentSelector) + ); + } + + // after instantiation and any addItems, we must have + // atleast one valid item. If not, plugin is misconfigured. + if (this.getItems().length < 1) { + throw new Error("addItems found no valid items."); + } + + if (opts.handleRepaint) { + this._handleRepaint(); + } + }, + + /** + * Remove any classes added during + * use and unbind all events. + */ + destroy: function (removeMarkup) { + removeMarkup = removeMarkup || false; + + if (removeMarkup) { + this.each(function (item) { + item.el.remove(); + }); + } + + // cleanup dom / events and + // run any user code + this._trigger("destroy"); + + // plugin wrapper disallows multiple scrollstory + // instances on the same element. after a destory, + // allow plugin to reattach to this element. + this.el.setAttribute("data-plugin_" + pluginName, null); + + // TODO: destroy the *instance*? + }, + + /** + * Update items' scroll positions and + * determine which one is active based + * on those positions. Useful during + * scrolls, resizes and other events + * that repaint the page. + * + * updateOffsets should be used + * with caution, as it's CPU intensive, + * and only useful it item sizes or + * scrollOffsets have changed. + * + * @param {Boolean} updateOffsets + * @return {[type]} [description] + */ + _handleRepaint: function (updateOffsets) { + updateOffsets = updateOffsets === false ? false : true; + + if (updateOffsets) { + this.updateOffsets(); // must be called first + } + + this._updateScrollPositions(); // must be called second + this._setActiveItem(); // must be called third + }, + + /** + * Keep state correct while scrolling + */ + _handleScroll: function () { + if (this.options.enabled) { + this._handleRepaint(false); + this._trigger("containerscroll"); + } + }, + + /** + * Keep state correct while resizing + */ + _handleResize: function () { + winHeight = window.innerHeight; + + if (this.options.enabled && this.options.autoUpdateOffsets) { + if (offsetIsAPercentage(this.options.triggerOffset)) { + this.updateTriggerOffset(this.options.triggerOffset); + } + + if (offsetIsAPercentage(this.options.scrollOffset)) { + this.updateScrollOffset(this.options.scrollOffset); + } + + this._debouncedHandleRepaint(); + this._trigger("containerresize"); + } + }, + + // Handlers for public events that maintain state + // of the ScrollStory instance. + + _onSetup: function () { + this.el.classList.add(pluginName); + }, + + _onDestroy: function () { + // remove events + this.el.removeEventListener(eventNameSpace); + window.removeEventListener(eventNameSpace); + + // item classes + // VANILLA_FIXME: should we use es6? + var itemClassesToRemove = [ + "scrollStoryItem", + "inviewport", + "active", + "filtered", + ]; + itemClassesToRemove.forEach((d) => item.el.classList.remove(d)); + + // container classes + // VANILLA_FIXME: should we use es6? + var classesToRemove = [...this.el.classList].filter( + (d) => d.lastIndexOf(pluginName, 0) === 0 + ); + classesToRemove.forEach((c) => this.el.classList.remove(c)); + + this.$trigger.parentNode.removeChild(this.$trigger); + }, + + _onContainerActive: function () { + this.el.classList.add(pluginName + "Active"); + }, + + _onContainerInactive: function () { + this.el.classList.remove(pluginName + "Active"); + }, + + _onItemFocus: function (e) { + const item = e.detail.el; + item.classList.add("active"); + this._manageContainerClasses("scrollStoryActiveItem-", item.id); + + // VANILLA_FIXME: implement categories somewhat + // trigger catgory change if not previously active or + // this item's category is different from the last + // if (item.category) { + // if ( (this.getPreviousItem() && this.getPreviousItem().category !== item.category) || !this.isContainerActive()) { + // this._trigger('categoryfocus', null, item.category); + + // if (this.getPreviousItem()) { + // this._trigger('categoryblur', null, this.getPreviousItem().category); + // } + // } + // } + }, + + _onItemBlur: function (e) { + const item = e.detail.el; + this._previousItems.unshift(item); + item.classList.remove("active"); + }, + + _onItemEnterViewport: function (e) { + e.detail.el.classList.add("inviewport"); + }, + + _onItemExitViewport: function (e) { + e.detail.el.classList.remove("inviewport"); + }, + + _onItemFilter: function (ev, item) { + item.el.addClass("filtered"); + if (this.options.autoUpdateOffsets) { + this._debouncedHandleRepaint(); + } + }, + + _onItemUnfilter: function (ev, item) { + item.el.removeClass("filtered"); + if (this.options.autoUpdateOffsets) { + this._debouncedHandleRepaint(); + } + }, + + _onCategoryFocus: function (ev, category) { + this._manageContainerClasses("scrollStoryActiveCategory-", category); + }, + + _onTriggerOffsetUpdate: function (ev, offset) { + this.$trigger.style.top = offset + "px"; + }, + + /** + * Given a prefix string like 'scrollStoryActiveCategory-', + * and a value like 'fruit', add 'scrollStoryActiveCategory-fruit' + * class to the containing element after removing any other + * 'scrollStoryActiveCategory-*' classes + * @param {[type]} prefix [description] + * @param {[type]} value [description] + * @return {[type]} [description] + */ + _manageContainerClasses: function (prefix, value) { + // VANILLA_FIXME: should we use es6? + var classesToRemove = [...this.el.classList].filter( + (d) => d.lastIndexOf(prefix, 0) === 0 + ); + classesToRemove.forEach((c) => this.el.classList.remove(c)); + this.el.classList.add(prefix + value); + }, + + /** + * Given a jQuery selection, add those elements + * to the internal items array. + * + * @param {Object} $jQuerySelection + */ + _prepItemsFromSelection: function (selection) { + // VANILLA_FIXME: use es6? + selection.forEach((div) => { + this._addItem({}, div); + }); + }, + + /** + * Given array of data, append markup and add + * data to internal items array. + * @param {Array} items + */ + _prepItemsFromData: function (items) { + // drop period from the default selector, so we can + // add it to the class attr in markup + var selector = this.options.contentSelector.replace(/\./g, ""); + + var frag = document.createDocumentFragment(); + items.forEach((data) => { + var item = document.createElement("div"); + item.className = selector; + this._addItem(data, item); + frag.appendChild(item); + }); + + this.el.appendChild(frag); + }, + + /** + * Given item user data, and an aleady appended + * jQuery object, create an item for internal items array. + * + * @param {Object} data + * @param {Node} el + */ + _addItem: function (data, el) { + var domData = el.dataset; + + var item = { + index: this._items.length, + el: el, + // id is from markup id attribute, data or dynamically generated + id: el.getAttribute("id") + ? el.getAttribute("id") + : data.id + ? data.id + : "story" + instanceCounter + "-" + this._items.length, + + // item's data is from client data or data-* attrs. prefer data-* attrs over client data. + data: Object.assign({}, data, domData), + + category: domData.category || data.category, // string. optional category slug this item belongs to. prefer data-category attribute + tags: data.tags || [], // optional tag or tags for this item. Can take an array of string, or a cvs string that'll be converted into array of strings. + scrollStory: this, // reference to this instance of scrollstory + + // in-focus item + active: false, + + // has item been filtered + filtered: false, + + // on occassion, the scrollToItem() offset may need to be adjusted for a + // particular item. this overrides this.options.scrollOffset set on instantiation + scrollOffset: false, + + // on occassion we want to trigger an item at a non-standard offset. + triggerOffset: false, + + // if any part is viewable in the viewport. + inViewport: false, + }; + + // ensure id exist in dom + if (!el.getAttribute("id")) { + el.setAttribute("id", item.id); + } + + el.classList.add("scrollStoryItem"); + + // global record + this._items.push(item); + + // quick lookup + this._itemsById[item.id] = item; + + this._trigger("itembuild", null, item); + + // An item's category is saved after the the itembuild event + // to allow for user code to specify a category client-side in + // that event callback or handler. + if (item.category && this._categories.indexOf(item.category) === -1) { + this._categories.push(item.category); + } + + // this._tags.push(item.tags); + }, + + /** + * Manage callbacks and event dispatching. + * + * Based very heavily on jQuery UI's implementaiton + * https://github.com/jquery/jquery-ui/blob/9d0f44fd7b16a66de1d9b0d8c5e4ab954d83790f/ui/widget.js#L492 + * + * @param {String} eventType + * @param {Object} event + * @param {Object} data + */ + _trigger: function (eventType, event, data) { + var callback = this.options[eventType]; + var prop, orig; + + if (isFunction(callback)) { + data = data || {}; + + // VANILLA_FIXME: not sure what this does + // can i comment? + // event = $.Event(event); + event = {}; + event.target = this.el; + event.type = eventType; + + // copy original event properties over to the new event + orig = event.originalEvent; + if (orig) { + for (prop in orig) { + if (!(prop in event)) { + event[prop] = orig[prop]; + } + } + } + + // fire event + trigger(event.target, eventType, data); + + // call the callback + var boundCb = this.options[eventType].bind(this); + boundCb(event, data); + } + }, + }; // end plugin.prototype + + /** + * Debounced version of prototype methods + */ + ScrollStory.prototype.debouncedUpdateOffsets = debounce( + ScrollStory.prototype.updateOffsets, + 100 + ); + ScrollStory.prototype._debouncedHandleRepaint = debounce( + ScrollStory.prototype._handleRepaint, + 100 + ); + + // A really lightweight plugin wrapper around the constructor, + // preventing multiple instantiations + return selectAll(options.container).forEach(function (node) { + if (!node.__scrollStory__) { + node.__scrollStory__ = true; + return new ScrollStory(options); + } + }); +} From a90c7e38c537ff8424c798fedc2b3faa3be473dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gonz=C3=A1lez?= Date: Thu, 21 May 2020 23:24:17 +0100 Subject: [PATCH 2/3] Implement categories --- scrollstory.js | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/scrollstory.js b/scrollstory.js index cb9bad5..0d623d0 100644 --- a/scrollstory.js +++ b/scrollstory.js @@ -1423,29 +1423,32 @@ function scrollStory(options) { this.el.classList.remove(pluginName + "Active"); }, - _onItemFocus: function (e) { - const item = e.detail.el; - item.classList.add("active"); - this._manageContainerClasses("scrollStoryActiveItem-", item.id); + _onItemFocus: function(e) { + var item = e.detail; + var el = item.el; + + el.classList.add('active'); + this._manageContainerClasses('scrollStoryActiveItem-', el.id); - // VANILLA_FIXME: implement categories somewhat // trigger catgory change if not previously active or // this item's category is different from the last - // if (item.category) { - // if ( (this.getPreviousItem() && this.getPreviousItem().category !== item.category) || !this.isContainerActive()) { - // this._trigger('categoryfocus', null, item.category); - - // if (this.getPreviousItem()) { - // this._trigger('categoryblur', null, this.getPreviousItem().category); - // } - // } - // } + if (item.category) { + if ( (this.getPreviousItem() && this.getPreviousItem().category !== item.category) || !this.isContainerActive()) { + this._trigger('categoryfocus', null, item.category); + + if (this.getPreviousItem()) { + this._trigger('categoryblur', null, this.getPreviousItem().category); + } + } + } }, - _onItemBlur: function (e) { - const item = e.detail.el; + _onItemBlur: function(e) { + const item = e.detail; + const el = item.el; + this._previousItems.unshift(item); - item.classList.remove("active"); + el.classList.remove('active'); }, _onItemEnterViewport: function (e) { @@ -1470,8 +1473,9 @@ function scrollStory(options) { } }, - _onCategoryFocus: function (ev, category) { - this._manageContainerClasses("scrollStoryActiveCategory-", category); + _onCategoryFocus: function(ev) { + const category = ev.detail; + this._manageContainerClasses('scrollStoryActiveCategory-',category); }, _onTriggerOffsetUpdate: function (ev, offset) { From f5951e890a51a6968bb45a1f191309139eb10754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Gonz=C3=A1lez?= Date: Thu, 21 May 2020 23:28:40 +0100 Subject: [PATCH 3/3] Remove constants --- scrollstory.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scrollstory.js b/scrollstory.js index 0d623d0..d182477 100644 --- a/scrollstory.js +++ b/scrollstory.js @@ -1444,8 +1444,8 @@ function scrollStory(options) { }, _onItemBlur: function(e) { - const item = e.detail; - const el = item.el; + var item = e.detail; + var el = item.el; this._previousItems.unshift(item); el.classList.remove('active'); @@ -1474,7 +1474,7 @@ function scrollStory(options) { }, _onCategoryFocus: function(ev) { - const category = ev.detail; + var category = ev.detail; this._manageContainerClasses('scrollStoryActiveCategory-',category); },