Last active
October 10, 2022 16:26
-
-
Save mikegwhit/2762001dd76da875d1fbd47294dc9c6b to your computer and use it in GitHub Desktop.
A class for wrapping window.scrollTo with a callback and a scroll queue.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* ScrollOptions | |
*/ | |
class ScrollOptions { | |
constructor(obj) { | |
/** | |
* @type {Function} Function called when a scroll in occurs. | |
*/ | |
this.scrollIn = function() {}; | |
this.scrolledIn = false; | |
/** | |
* @type {Function} Function called when a scroll out occurs. | |
*/ | |
this.scrollOut = function() {}; | |
this.scrolledOut = false; | |
/** | |
* @type {Function} Function called when the scroll occurs in | |
* this element. | |
*/ | |
this.scrollItv = function() {}; | |
/** | |
* @type {String} Where in the page to offset the scroll to. | |
* Use top for when the top window boundary, center for center window | |
* boundary, bottom for when the bottom window boundary. | |
*/ | |
this.scrollPosition = 'top'; | |
/** | |
* @type {String} Where in the element to start the scroll. | |
* Use top for when the element top crosses the scroll start boundary, | |
* center for when the element center start crosses the scroll start | |
* boundary, bottom for when the element bottom crosses the scroll | |
* start boundary. | |
*/ | |
this.elementStart = 'top'; | |
/** | |
* @type {String} Where in the page to end the scroll. | |
*/ | |
this.elementEnd = 'bottom'; | |
/** | |
* @type {Number} The number of pixels to offset the scroll start. | |
*/ | |
this.scrollOffset = 0; | |
/** | |
* @type {Number} The number of pixels to offset the element start. | |
*/ | |
this.elementStartOffset = 0; | |
/** | |
* @type {Number} The number of pixels to offset the element start. | |
*/ | |
this.elementEndOffset = 0; | |
Object.assign(this, obj); | |
} | |
} | |
/** | |
* Scroll effects. | |
*/ | |
class Scroll { | |
/** | |
* Bind the scroll event. | |
*/ | |
static bindEvent() { | |
window.addEventListener('scroll', Scroll.scrollHandler); | |
} | |
/** | |
* Checks the scroll elements to see if they are in view. | |
*/ | |
static checkScrollElements() { | |
for (let id in Scroll.state.elements) { | |
const el = Scroll.state.elements[id] | |
if (!el) { | |
delete Scroll.state.elements[id]; | |
continue; | |
} | |
let { | |
el: element, | |
scrollIn, | |
// scrolledIn, | |
scrollOut, | |
// scrolledOut, | |
scrollItv, | |
elementStart, | |
elementEnd, | |
elementStartOffset = 0, | |
elementEndOffset = 0, | |
staticPosition, | |
staticSize, | |
offsetHeight, | |
offsetTop, | |
offsetLeft | |
} = el; | |
let theElementStart, theElementEnd; | |
if (typeof elementStartOffset === 'function') { | |
elementStartOffset = elementStartOffset(); | |
} | |
if (typeof elementEndOffset === 'function') { | |
elementEndOffset = elementEndOffset(); | |
} | |
const theScroll = Scroll.getScroll(null, el); | |
const lastScroll = Scroll.getScroll(Scroll.state.lastScrollY, el); | |
const scrolledUp = lastScroll > theScroll; | |
if (!staticPosition) { | |
offsetTop = el.offsetTop = element.offsetTop; | |
offsetLeft = el.offsetLeft = element.offsetLeft; | |
} | |
if (!staticSize) { | |
offsetHeight = element.offsetHeight; | |
} | |
switch (elementStart) { | |
case 'top': | |
theElementStart = offsetTop + elementStartOffset; | |
break; | |
case 'bottom': | |
theElementStart = offsetTop + offsetHeight | |
+ elementStartOffset; | |
break; | |
case 'center': | |
theElementStart = offsetTop; | |
break; | |
} | |
switch (elementEnd) { | |
case 'top': | |
theElementEnd = offsetTop + offsetHeight + elementEndOffset; | |
break; | |
case 'bottom': | |
theElementEnd = offsetTop + offsetHeight | |
+ window.innerHeight | |
+ elementEndOffset; | |
break; | |
case 'center': | |
theElementEnd = offsetTop + offsetHeight | |
+ window.innerHeight * 0.5 + elementEndOffset; | |
break; | |
} | |
if (theElementStart > theElementEnd) { | |
console.warn('Scroll watch scroll line greater than element ' | |
+ 'height. This edge case is not supported. Instead, use' | |
+ 'scrollStart with value of \'bottom\' to achieve this.'); | |
} | |
if (!scrolledUp && (lastScroll < theElementStart || !el.scrolledIn) | |
&& theScroll >= theElementStart | |
&& theScroll <= theElementEnd) { | |
el.scrolledIn = true; | |
el.scrolledOut = false; | |
scrollIn(el, !scrolledUp); | |
} else if (scrolledUp | |
&& (lastScroll > theElementEnd || !el.scrolledOut) | |
&& theScroll <= theElementEnd | |
&& theScroll >= theElementStart) { | |
el.scrolledIn = true; | |
el.scrolledOut = false; | |
scrollIn(el, !scrolledUp); | |
} | |
if (scrolledUp && (lastScroll > theElementStart || !el.scrolledOut) | |
&& theScroll <= theElementStart) { | |
el.scrolledOut = true; | |
el.scrolledIn = false; | |
scrollOut(el, !scrolledUp); | |
} else if (!scrolledUp | |
&& (lastScroll <= theElementEnd || !el.scrolledOut) | |
&& theScroll > theElementEnd) { | |
el.scrolledOut = true; | |
el.scrolledIn = false; | |
scrollOut(el, !scrolledUp); | |
} | |
scrollItv(el, !scrolledUp); | |
} | |
} | |
/** | |
* Gets the position of the element. | |
*/ | |
static getPosition(element) { | |
return { | |
top: element.offsetTop, | |
bottom: element.offsetTop + element.offsetHeight | |
}; | |
} | |
/** | |
* @param {ScrollOptions} options | |
*/ | |
static getScroll(theScroll, options) { | |
if (!theScroll) { | |
theScroll = window.scrollY; | |
} | |
if (!options) { | |
options = new ScrollOptions(); | |
} | |
let {scrollPosition, scrollOffset} = options; | |
if (typeof scrollOffset === 'function') { | |
scrollOffset = scrollOffset(); | |
} | |
switch (scrollPosition) { | |
case 'top': | |
// From the top of screen. | |
return theScroll + scrollOffset; | |
case 'bottom': | |
// From the bottom value of screen. | |
return theScroll + window.innerHeight | |
+ scrollOffset; | |
case 'center': | |
// From center screen. | |
return theScroll + window.innerHeight * 0.5 | |
+ scrollOffset; | |
} | |
} | |
/** | |
* Reset scroll state. | |
*/ | |
static reset(andLastScroll) { | |
Scroll.state.isScrolling = false; | |
Scroll.state.currentScroll = null; | |
Scroll.state.queue = []; | |
if (andLastScroll) { | |
Scroll.state.lastScroll = null; | |
} | |
} | |
/** | |
* Callback for scroll timer, used to detect if scroll has stopped. | |
*/ | |
static scrollComplete() { | |
if (!!Scroll.state.currentScroll | |
&& Scroll.state.currentScroll.callback) { | |
Scroll.state.currentScroll.callback(); | |
} | |
if (!!Scroll.state.currentScroll) { | |
Scroll.state.lastScroll = Scroll.state.currentScroll; | |
} | |
Scroll.reset(); | |
if (Scroll.state.queue.length) { | |
Scroll.scrollTo(Scroll.state.queue.shift()); | |
} | |
} | |
/** | |
* Handles the scroll event with the goal to be to log the last scroll. | |
*/ | |
static scrollHandler() { | |
Scroll.checkScrollElements(); | |
Scroll.state.lastScrollY = window.scrollY; | |
if (!Scroll.initiatedScroll) { | |
Scroll.userScrolled = true; | |
Scroll.reset(true); | |
return; | |
} | |
if (!!Scroll.state.currentScroll | |
&& !!Scroll.state.currentScroll.element) { | |
const {top, left} = Scroll | |
.getPosition(Scroll.state.currentScroll.element); | |
if (top !== Scroll.state.currentScroll.top || | |
left !== Scroll.state.currentScroll.left) { | |
// The offset of the element has changed so we should restart | |
// the scroll. | |
Scroll.scrollTo(Scroll.state.currentScroll, true); | |
return; | |
} | |
} | |
if (!!Scroll.state.scrollTimer) { | |
clearTimeout(Scroll.state.scrollTimer); | |
} | |
Scroll.state.scrollTimer = setTimeout(Scroll.scrollComplete, | |
Scroll.config.scrollTimeout); | |
} | |
/** | |
* Wrapper for window.scrollTo but with callbacks, offsets and updating | |
* the scroll target which is helpful during page loads. | |
* @param {Object} options | |
* @param {Boolean} override | |
*/ | |
static scrollTo(options, override = false) { | |
let {top, left, behavior, element, offset} = options; | |
if (!!element) { | |
const position = Scroll.getPosition(element); | |
top = position.top; | |
left = position.left; | |
options.top = top; | |
options.left = left; | |
} | |
if (Scroll.state.isScrolling && !override) { | |
Scroll.state.queue.push({...options, fromQueue: true}); | |
return; | |
} | |
if (!offset) { | |
offset = {top: 0, left: 0}; | |
if (!!Scroll.defaultOffset) { | |
offset = Scroll.defaultOffset; | |
} | |
} else if (!offset.top) { | |
offset.top = 0; | |
} else if (!offset.left) { | |
offset.left = 0; | |
} | |
Scroll.initiatedScroll = true; | |
window.scrollTo({ | |
top: top + offset.top, | |
left: left + offset.left, | |
behavior: 'instant' | |
}); | |
setTimeout(() => { | |
Scroll.initiatedScroll = false; | |
}, Scroll.config.initiatedScrollTimeout) | |
Scroll.state.currentScroll = options; | |
Scroll.state.isScrolling = true; | |
Scroll.state.scrollTimer = setTimeout(Scroll.scrollComplete, | |
Scroll.config.scrollTimeout); | |
} | |
/** | |
* Synchronizes a page given the last scroll target, provided the user has | |
* not initiated a scroll. | |
*/ | |
static syncPageChange() { | |
if (!!Scroll.state.lastScroll) { | |
Scroll.scrollTo(Scroll.state.lastScroll); | |
} else if (!Scroll.userScrolled) { | |
const el = document.getElementById(window.location.hash.slice(1)); | |
if (!!el) { | |
Scroll.scrollTo({element: el}); | |
} | |
} | |
} | |
/** | |
* Removes an element from being watched for scroll events. | |
*/ | |
static unwatchScroll(el) { | |
Scroll.state.elements[el.getAttribute('id')] = null; | |
} | |
/** | |
* Adds an element to be watched for scroll events. | |
* @param {HTMLElement} el | |
* @param {ScrollOptions} opts | |
*/ | |
static watchScroll(el, opts) { | |
if (!el) { | |
return; | |
} | |
Scroll.state.elements[el.getAttribute('id')] = { | |
el, | |
...(new ScrollOptions(opts)), | |
...{ | |
offsetTop: el.offsetTop, | |
offsetLeft: el.offsetLeft, | |
offsetHeight: el.offsetHeight, | |
} | |
}; | |
} | |
} | |
Scroll.state = { | |
queue: [], | |
elements: {} | |
}; | |
Scroll.config = { | |
initiatedScrollTimeout: false, | |
scrollTimeout: 100, | |
userScrolled: false, | |
} | |
Scroll.bindEvent(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment