Skip to content

Instantly share code, notes, and snippets.

@mikegwhit
Last active October 10, 2022 16:26
Show Gist options
  • Save mikegwhit/2762001dd76da875d1fbd47294dc9c6b to your computer and use it in GitHub Desktop.
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.
/**
* 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