vz-scroll-interactions
Version:
A lightweight, universal scroll interaction library that works with any framework - vanilla JS, React, Vue, and more
396 lines (327 loc) • 12.2 kB
JavaScript
class ScrollEventManager {
constructor() {
this.instances = new Map();
this.boundHandleScroll = this.handleScroll.bind(this);
this.isListenerAttached = false;
}
// Create a new scroll event instance
createInstance(key, callback, options = {}) {
const {
enabled = true,
throttle = 0
} = options;
// If instance already exists, update it
if (this.instances.has(key)) {
const existingInstance = this.instances.get(key);
existingInstance.callback = callback;
existingInstance.enabled = enabled;
return existingInstance;
}
// Create new instance
const instance = {
key,
callback,
enabled,
lastCall: 0,
throttle
};
this.instances.set(key, instance);
this.attachGlobalListener();
return instance;
}
// Global scroll event handler
handleScroll(event) {
const now = Date.now();
this.instances.forEach(instance => {
// Skip if instance is disabled
if (!instance.enabled) return;
// Check throttling
if (now - instance.lastCall >= instance.throttle) {
instance.lastCall = now;
try {
instance.callback(event);
} catch (error) {
console.error(`Scroll event error for ${instance.key}:`, error);
}
}
});
}
// Attach global scroll listener only once
attachGlobalListener() {
if (!this.isListenerAttached && typeof window !== 'undefined') {
window.addEventListener('scroll', this.boundHandleScroll, { passive: true });
this.isListenerAttached = true;
}
}
// Remove a specific scroll instance
removeInstance(key) {
this.instances.delete(key);
// Remove global listener if no instances remain
if (this.instances.size === 0 && typeof window !== 'undefined') {
window.removeEventListener('scroll', this.boundHandleScroll);
this.isListenerAttached = false;
}
}
// Toggle an instance's enabled state
toggleInstance(key, enabled) {
const instance = this.instances.get(key);
if (instance) {
instance.enabled = enabled;
}
}
// Get all current instances
getInstances() {
return Array.from(this.instances.keys());
}
// Cleanup all instances
cleanup() {
if (typeof window !== 'undefined' && this.isListenerAttached) {
window.removeEventListener('scroll', this.boundHandleScroll);
this.isListenerAttached = false;
}
this.instances.clear();
}
}
let VzSIFactor = 1;
function setVzSIFactor(param) {
VzSIFactor = param;
}
class ScrollInteractionManager {
constructor() {
this.instances = new Map();
this.scrollManager = new ScrollEventManager();
this.vw = 0;
this.boundHandleResize = this.handleResize.bind(this);
this.resizeTimeout = null;
this.isListenerAttached = false;
}
initialize() {
if (typeof window === 'undefined') return;
if (!this.isListenerAttached) {
this.vw = window.innerWidth;
this.setupResizeListener();
this.isListenerAttached = true;
}
}
cleanup() {
if (typeof window === 'undefined') return;
if (this.isListenerAttached) {
window.removeEventListener('resize', this.boundHandleResize);
this.isListenerAttached = false;
}
if (this.resizeTimeout) {
window.cancelAnimationFrame(this.resizeTimeout);
this.resizeTimeout = null;
}
this.scrollManager.cleanup();
this.instances.clear();
}
setupResizeListener() {
if (typeof window === 'undefined') return;
const debouncedResize = () => {
if (this.resizeTimeout) {
window.cancelAnimationFrame(this.resizeTimeout);
}
this.resizeTimeout = window.requestAnimationFrame(() => {
this.handleResize();
});
};
window.addEventListener('resize', debouncedResize, { passive: true });
}
handleResize() {
if (typeof window === 'undefined') return;
this.vw = window.innerWidth;
this.instances.forEach((instance, key) => {
const { element, options, state } = instance;
const ref = element.current || element.target || element;
if (!ref) return;
const start = this.calcTop(ref, options.startPoint);
const end = this.calcBot(ref, options.endPoint);
const scroll = window.scrollY;
const percentages = [];
state.scrolled = false;
if (options.unrestrictedCallback) {
options.unrestrictedCallback({ ref, vw: this.vw });
}
this.triggerScroll(instance, scroll, start, end, percentages);
});
}
createInstance(key, element, options = {}) {
this.initialize();
const {
values = [],
startPoint = [0.5],
endPoint = [0.15, 1],
callback,
unrestrictedCallback = null,
isUnrestricted = false
} = options;
if (this.instances.has(key)) {
const existingInstance = this.instances.get(key);
Object.assign(existingInstance.options, options);
return existingInstance;
}
const instance = {
element,
options: {
values,
startPoint,
endPoint,
callback,
isUnrestricted,
unrestrictedCallback
},
state: {
scrolled: false,
init: false
}
};
this.instances.set(key, instance);
this.initializeScrollListener(key);
return instance;
}
removeInstance(key) {
this.instances.delete(key);
if (this.scrollManager) {
this.scrollManager.removeInstance(key);
}
if (this.instances.size === 0) {
this.cleanup();
}
}
initializeScrollListener(key) {
const instance = this.instances.get(key);
if (!instance || !this.scrollManager) return;
this.scrollManager.createInstance(key, () => {
this.handleScroll(key);
});
}
handleScroll(key) {
if (typeof window === 'undefined') return;
const instance = this.instances.get(key);
if (!instance) return;
const { element, options, state } = instance;
const ref = element.current || element.target || element;
if (!ref) return;
const start = this.calcTop(ref, options.startPoint);
const end = this.calcBot(ref, options.endPoint);
const scroll = window.scrollY;
const percentages = [];
if (options.unrestrictedCallback && !state.init) {
state.init = true;
this.triggerScroll(instance, scroll, start, end, percentages);
state.scrolled = true;
return;
}
this.triggerScroll(instance, scroll, start, end, percentages);
}
triggerScroll(instance, scroll, start, end, percentages) {
const { element, options, state } = instance;
const ref = element.current || element.target || element;
if (start > scroll && !state.scrolled) {
this.calculatePercentages(percentages, options.values, 0);
options.callback({ v: percentages, ref, vw: this.vw });
state.scrolled = true;
} else if (scroll > end && !state.scrolled) {
this.calculatePercentages(percentages, options.values, 1);
options.callback({ v: percentages, ref, vw: this.vw });
state.scrolled = true;
}
if (scroll <= end && start <= scroll) {
const progress = (scroll - start) / (end - start);
this.calculatePercentages(percentages, options.values, progress);
options.callback({ v: percentages, ref, vw: this.vw, start, end });
if (state.scrolled) {
state.scrolled = false;
}
}
}
calculatePercentages(percentages, values, progress) {
values.forEach(([start, end]) => {
percentages.push(start + (end - start) * progress);
});
}
toggleInstance(key, enabled) {
if (this.scrollManager) {
this.scrollManager.toggleInstance(key, enabled);
}
}
calcTop(ref, customStart) {
if (typeof window === 'undefined') return 0;
let dist = this.distance(ref);
const height = window.innerHeight / VzSIFactor;
const clientHeight = ref.clientHeight / VzSIFactor;
if (customStart) {
if (Array.isArray(customStart) && customStart.length) {
if (customStart.length === 1) {
return dist - (height * (1 - customStart[0]));
} else if (customStart.length === 2) {
return dist - (height * (1 - customStart[0])) + (clientHeight * customStart[1]);
} else if (customStart.length === 3) {
return customStart[2];
} else if (customStart.length === 4) {
return dist - (height * (1 - customStart[0])) + (clientHeight * customStart[1]) + (customStart[3] / VzSIFactor);
}
} else {
return dist - (height * (customStart ? (1 - customStart) : 1));
}
}
return dist - height;
}
calcBot(ref, customEnd) {
if (typeof window === 'undefined') return 0;
let dist = this.distance(ref);
const height = window.innerHeight / VzSIFactor;
const clientHeight = ref.clientHeight / VzSIFactor;
dist += clientHeight;
if (customEnd) {
if (Array.isArray(customEnd) && customEnd.length) {
if (customEnd.length === 1) {
return dist - (height * customEnd[0]);
} else if (customEnd.length === 2) {
return dist - (height * customEnd[0]) - (clientHeight * customEnd[1]);
} else if (customEnd.length === 3) {
return customEnd[2];
} else if (customEnd.length === 4) {
return dist - (height * (1 - customEnd[0])) + (clientHeight * customEnd[1]) + (customEnd[3] / VzSIFactor);
}
} else {
return dist - (height * (customEnd ? customEnd : 1));
}
}
return dist;
}
distance(ref) {
let distance = 0;
let currentElement = ref;
while (currentElement.offsetParent) {
distance += currentElement.offsetTop;
currentElement = currentElement.offsetParent;
}
return (distance / VzSIFactor);
}
calcPercent(start, end, current) {
return (start === end) ? 100 : ((current - start) / (end - start)) * 100;
}
calcCurrent(start, end, percentage) {
return start + (percentage / 100) * (end - start);
}
calcRefTop(element, num) {
if (Array.isArray(element)) {
return this.calcTop(element[0].current, num || 0);
} else {
return this.calcTop(element.current || element, num || 0);
}
}
}
let scrollTriggerInstance = null;
function getScrollTrigger() {
if (typeof window === 'undefined') return null;
if (!scrollTriggerInstance) {
scrollTriggerInstance = new ScrollInteractionManager();
}
return scrollTriggerInstance;
}
var scrollTrigger = getScrollTrigger();
// Export both classes and the singleton instance
export { ScrollEventManager, ScrollInteractionManager, VzSIFactor, scrollTrigger as default, getScrollTrigger, setVzSIFactor };