scroll-captain
Version:
Scroll Captain is a JS library for creating scroll-triggered animations.
1,027 lines (881 loc) • 96.6 kB
JavaScript
class ScrollCaptain {
static instancesCompleted = 0;
static totalInstances = 0;
constructor(element, options) {
ScrollCaptain.instanceCount++;
// Predefined options
this.defaultOptions = {
initAttr: 'data-scrollcaptain', // String - The data attribute that identifies the instance elements.
triggerSuffix: null, // String - An optional suffix appended to the initAttr to define a specific element within the root element as the trigger.
triggerIndex: -1, // Number - Index of the current trigger among all triggers of the same type (relevant for multiple triggers that affect global targets)
triggerPosition: 0, // Number - The percentage of trigger visibility at which the animation is activated.
top: null, // String - The position of the upper animation border (positive value for below the viewport top, negative value for above the viewport top, e.g., '100%' or '-100px').
bottom: null, // String - The position of the lower animation border (positive value for below the viewport bottom, negative value for above the viewport bottom, e.g., '100%' or '-100px').
cssSpace: null, // String - Name for a CSS variable applied to the root element, including the animation area height as its value.
onInit: null, // Function - A callback function called when the instance is initialized (provides animation progress as a parameter).
onEnter: null, // Function - A callback function called when the trigger enters the animation area (provides animation progress as a parameter).
onLeave: null, // Function - A callback function called when the trigger leaves the animation area (provides animation progress as a parameter).
onScroll: null, // Function - A callback function called on each scroll event when the trigger moves within the animation area (provides animation progress as a parameter).
onResize: null, // Function - A callback function called when the window size changes (provides animation progress as a parameter).
updateOnResize: null, // Function - A callback function called when the window size changes (before recalculating the animation area). Useful to modify options before.
devMode: false, // Boolean - Enable/disable developer mode to log information.
breakpoints: null // Object – An object with settings for different screen sizes. Breakpoints act as minimum values for screen width.
};
// Settings – Predefined
this.cssProperties = { // Object – Animatable css properties
transform: ['translateX', 'translateY', 'translateZ', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'skewX', 'skewY', 'perspective'],
colors: ['backgroundColor', 'color', 'borderColor', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'fill', 'stroke'],
others: ['opacity']
};
// Settings – Options
this.options = options; // Object – Defined instance options
this.settings = this.mergeOptions(this.defaultOptions, this.options); // Object – Containing default and defined instance options
this.currentSettings = this.deepCopy(this.settings); // Object – Copy of original settings object
this.settingsBackup = this.deepCopy(this.settings); // Object – Copy of original settings object
// Elements
this.$root = element; // Object – Root element of the animation
this.$trigger = this.settings.triggerSuffix !== null ? this.$root.querySelector(`[${this.settings.initAttr}=${this.settings.triggerSuffix}]`) : this.$root; // Object – Element that triggers the animation
// Settings – General
this.initial = true; // Boolean – Is set to 'false' when the trigger was intersected for the first time
this.isResizing = false; // Boolean – Is set to 'true' while resize updates are executed and set to 'false' if the are finished
this.isObserverConnected = false; // Boolean – Is set to 'true' if observer is activated
this.triggerSuffix = this.$trigger.getAttribute(this.settings.initAttr); // String - Identifying trigger suffix
this.onscroll = false; // Boolean – Indicates if instance needs specific actions while scrolling
this.initialCssProperties = {}; // Object – Stores all initial animatable CSS properties for each target of each animation
this.animations = {}; // Object – Stores all defined animations and its css animation frames
this.viewportHeight = window.innerHeight; // Number – Current inner height of the browser window
this.viewportWidth = window.innerWidth; // Number – Current width of the browser window
this.viewportOuterWidth = window.outerWidth; // Number – Current width of the browser window
this.currentBreakpoint = null; // Number – Current active breakpoint
this.stickyAnimations = []; // Array – Stores information of which animation is sticky
this.requiredUpdates = { // Object – Stores information of which actions have to be executed while update() is called
observer: false,
cssSpace: false,
onscroll: [],
stickyElements: [],
animations: [],
transition: []
};
// Settings – Animation area
this.startPosition = null;
this.top = [0, 'px']; // Array – [number (upper animation border > distance to viewport top), string (unit)]
this.bottom = [0, 'px']; // Array – [number (lower animation border > distance to viewport bottom), string (unit)]
this.animationArea = null; // Number – Height of the animation area (calculated in pixel)
// Settings – Animation state
this.animationProgress = null; // Number – Current animation progress
this.storedProgress = null; // Number - Stored animation progress, used to resume the animation from a different position on page reload
this.animationActive = false; // Boolean – Indicates if animation is currently active
// Settings – Trigger
this.passed = false; // Boolean – Indicates if trigger has already passed the animation area
this.isCrossingBorder = false; // Boolean – Indicates if trigger is currently crossing an animation border (could be enter or leave)
this.isEntering = false; // Boolean – Indicates if trigger is entering the animation area
this.isEnteringFirstTime = null; // Boolean – Indicates if trigger is entering the animation area for the first time
this.isEnteringOnTop = false; // Boolean – Indicates if trigger is entering the animation area by upper border
this.isEnteringOnBottom = false; // Boolean – Indicates if trigger is entering the animation area by lower border
this.isLeaving = false; // Boolean – Indicates if trigger is leaving the animation area
this.isLeavingOnTop = false; // Boolean – Indicates if trigger is leaving the animation area by upper border
this.isLeavingOnBottom = false; // Boolean – Indicates if trigger is leaving the animation area by lower border
if (this.$trigger) {
this.initialize();
}
}
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
// INITIALIZE
/**
* Prepares the animation settings, updates the page position if required, sets up event listeners, and initiates the trigger observation.
* @returns {void}
*/
initialize() {
this.prepareAnimation();
this.setEvents();
// Start observing the trigger element after a short delay, as potential DOM manipulations might affect the intersection state (e.g., influenced by cssSpace or other functions)
window.setTimeout(() => {
this.observeTrigger();
}, 300);
}
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
// PUBLIC METHODS
/**
* Updates the animation state and applies necessary changes based on viewport resize and other settings.
* @param {number} widthChange - Boolean that indicates if function was triggered by window width resize.
* @returns {void}
*/
update(widthChange) {
const currentBreakpoint = this.currentBreakpoint;
let widthResize = false;
// Reset requiredUpdates object for new updates
this.requiredUpdates = {
observer: false,
cssSpace: false,
onscroll: [],
stickyElements: [],
animations: [],
transition: []
};
// Check if resizing has occurred
if (this.isResizing) {
widthResize = widthChange;
// If width has changed, update breakpoint options if defined
if (widthResize && this.settings.breakpoints !== null) {
this.currentSettings = this.deepCopy(this.settings);
this.mergeBreakpointOptions();
}
// Call updateOnResize if provided in settings
if (this.settings.updateOnResize !== null) {
this.settings.updateOnResize();
}
// Update sticky elements that need position and height adjustments
for (const animation in this.animations) {
if (this.stickyAnimations.includes(animation) || (!this.requiredUpdates.stickyElements.includes(animation) && this.settings[animation].sticky)) {
this.requiredUpdates.stickyElements.push(animation);
this[`${animation}Targets`].forEach(target => {
this.setSticky(target, this.settings[animation].sticky);
});
}
}
}
if ((this.isResizing && currentBreakpoint !== this.currentBreakpoint) || !this.isResizing || this.settings.updateOnResize !== null) {
// Detect settings changes and define required updates
this.compareAndDefineUpdates(this.currentSettings, this.settings);
// Update animation objects with changed CSS options
this.requiredUpdates.animations.forEach(animation => {
this.defineAnimations(animation);
});
// Update transition properties based on changes or scroll state
this.requiredUpdates.transition.forEach(animation => {
this[`${animation}Targets`].forEach((target, index) => {
this.setTransition(animation, target, index);
});
});
// Redefine onscroll state based on defined behaviors
this.onscroll = !!(typeof this.settings.onScroll === 'function' ||
Object.keys(this.animations).some(animation =>
this.settings[animation].onscroll || this.settings[animation].devMode
));
// Redefine animation area based on required updates
this.defineAnimationArea();
// Iterate through requiredUpdates and apply necessary actions
for (const option in this.requiredUpdates) {
if (typeof this.requiredUpdates[option] === 'boolean') {
if (option === 'observer') {
if (this.isObserverConnected) {
this.observer.disconnect(this.$trigger);
this.isObserverConnected = false;
}
this.defineObserverOptions();
}
if (option === 'cssSpace') {
if (this.requiredUpdates[option]) {
if (typeof this.settings.cssSpace === 'string') {
this.$root.style.setProperty(`--${this.settings.cssSpace}`, `${this.animationArea}px`);
} else {
this.$root.style.removeProperty(`--${this.currentSettings.cssSpace}`);
}
}
}
}
}
// Update sticky elements (add, update position and height, or delete)
this.requiredUpdates.stickyElements.forEach(animation => {
this[`${animation}Targets`].forEach(target => {
this.setSticky(target, this.settings[animation].sticky);
});
});
}
// Start observing the trigger element if not connected
if (!this.isObserverConnected) {
this.observeTrigger();
}
// Call onResize if provided in settings
if (this.settings.onResize !== null) {
this.settings.onResize(this.animationProgress);
}
this.currentSettings = this.deepCopy(this.settings);
}
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
// GENERAL CONFIGURATION
/**
* Merges two multidimensional objects, with priority given to the properties of the target object`.
* If a key in the top level of the target object includes the word 'animate', the properties of that key are merged together with a default animation object.
* @param {Object} defaultObject - The default object to merge with the targetObject.
* @param {Object} targetObject - The target object to merge with the defaultObject.
* @returns {Object} - The merged object.
*/
mergeOptions(defaultObject, targetObject) {
for (const key of Object.keys(targetObject)) {
// Check if the value of the current key in targetObject is an object
if (targetObject[key] instanceof Object) {
// Define default values for animation properties
const defaultAnimation = {
targetSuffix: 'target',
globalTarget: false,
sticky: false,
class: null,
onscroll: false,
resetOnScrollDown: true,
resetOnScrollUp: true,
easing: 'linear',
duration: 0.2,
delay: null,
improvePerformance: true
};
// Add predefined CSS properties
for (const category of Object.keys(this.cssProperties)) {
for (let index = 0; index < this.cssProperties[category].length; index++) {
defaultAnimation[this.cssProperties[category][index]] = null;
}
}
// Check if the key includes 'animate' and assign the default animation object
if (key.includes('animate')) {
defaultObject[key] = defaultAnimation;
Object.assign(targetObject[key], this.mergeOptions(defaultObject[key] || {}, targetObject[key]));
}
}
}
// Merge the defaultObject and targetObject
Object.assign(defaultObject || {}, targetObject);
// Return the merged object
return defaultObject;
}
/**
* Merges breakpoint options with the main settings based on the current viewport width.
* Updates specified settings for the matched breakpoint.
* @returns {void}
*/
mergeBreakpointOptions() {
// Recursively replace entries in object based on keys
const replaceEntries = (object, replaceObject, keys) => {
const nestedKeys = keys !== undefined ? keys : [];
for (const key in replaceObject) {
// Exclude certain keys from replacement
if (key !== 'initAttr' && key !== 'triggerSuffix' && key !== 'breakpoints' && key !== 'targetSuffix' && key !== 'globalTarget' && key !== 'onInit' && key !== 'onScroll' && key !== 'onEnter' && key !== 'onLeave' && key !== 'updateOnResize' && key !== 'onResize' && key !== 'devMode' && key !== 'cssSpace' && key !== 'class') {
if ((typeof replaceObject[key] === 'object' && replaceObject[key] !== null) && (typeof object[key] === 'object' && object[key] !== null)) {
nestedKeys.push(key);
replaceEntries(object[key], replaceObject[key], nestedKeys);
} else {
// Replace the value if it is different
if (object[key] !== replaceObject[key]) {
object[key] = replaceObject[key];
}
}
}
}
};
// Reset animation objects to original
for (const animation in this.animations) {
replaceEntries(this.settings[animation], this.settingsBackup[animation]);
}
// Find the matching breakpoint based on viewport width
let matchingBreakpoint = null;
let settingsToUpdate;
const validBreakpoints = Object.keys(this.settings.breakpoints).filter(key => !isNaN(parseInt(key)));
if (validBreakpoints.length < 1) return;
validBreakpoints.map(Number);
validBreakpoints.forEach(validBreakpoint => {
if (matchingBreakpoint === null) {
matchingBreakpoint = this.viewportWidth >= validBreakpoint ? validBreakpoint : matchingBreakpoint;
} else {
if (this.viewportWidth >= validBreakpoint && (this.viewportWidth - validBreakpoint) < (this.viewportWidth - matchingBreakpoint)) {
matchingBreakpoint = validBreakpoint;
}
}
});
// Update settings for the matched breakpoint
if (matchingBreakpoint !== null) {
for (let index = 0; index < validBreakpoints.length; index++) {
if (Number(matchingBreakpoint) >= Number(validBreakpoints[index])) {
settingsToUpdate = this.settingsBackup.breakpoints[validBreakpoints[index]];
replaceEntries(this.settings, settingsToUpdate);
}
}
} else {
settingsToUpdate = this.settingsBackup;
replaceEntries(this.settings, settingsToUpdate);
}
this.currentBreakpoint = matchingBreakpoint;
}
/**
* Validates animation options and values.
* @returns {void}
*/
checkValidity() {
const errorMessage = [];
// Categories to organize values for validation
const valuesToCheck = {
strings: {}, // For string options
arraysOrStrings: {}, // For options that can be an array or a string
colors: {}, // For color options
numbers: {}, // For numeric options
relativeNumbers: {}, // For relative numeric options (min 0 and max 1)
booleans: {}, // For boolean options
callbacks: {} // For callback function options
};
// Categorize options to corresponding checklist
const addToChecklist = (option, value) => {
// Strings
if (option === 'initAttr' || option === 'triggerSuffix' || option === 'cssSpace' || option === 'easing' || option === 'class' || option === 'top' || option === 'bottom') {
const newEntryIndex = Object.keys(valuesToCheck.strings).length + 1;
valuesToCheck.strings[newEntryIndex] = [option, value];
}
// Arrays or strings
if (option === 'targetSuffix') {
const newEntryIndex = Object.keys(valuesToCheck.arraysOrStrings).length + 1;
valuesToCheck.arraysOrStrings[newEntryIndex] = [option, value];
}
// Colors
if (this.cssProperties.colors.indexOf(option) !== -1) {
const newEntryIndex = Object.keys(valuesToCheck.colors).length + 1;
valuesToCheck.colors[newEntryIndex] = [option, value];
}
// Numbers
if (this.cssProperties.transform.indexOf(option) !== -1) {
const newEntryIndex = Object.keys(valuesToCheck.numbers).length + 1;
valuesToCheck.numbers[newEntryIndex] = [option, value];
}
// Relative numbers (min 0 and max 1)
if (option === 'opacity' || option === 'triggerPosition') {
const newEntryIndex = Object.keys(valuesToCheck.relativeNumbers).length + 1;
valuesToCheck.relativeNumbers[newEntryIndex] = [option, value];
}
// Booleans
if (option === 'onscroll' || option === 'resetOnScrollDown' || option === 'resetOnScrollUp' || option === 'sticky' || option === 'devMode' || option === 'globalTarget') {
const newEntryIndex = Object.keys(valuesToCheck.booleans).length + 1;
valuesToCheck.booleans[newEntryIndex] = [option, value];
}
// Callbacks
if (option === 'onInit' || option === 'onEnter' || option === 'onLeave' || option === 'onScroll' || option === 'onResize' || option === 'updateOnResize') {
const newEntryIndex = Object.keys(valuesToCheck.callbacks).length + 1;
valuesToCheck.callbacks[newEntryIndex] = [option, value];
}
};
// Check general options
const checkGeneralSettings = object => {
for (const option in object) {
if (object[option] !== null && typeof object[option] !== 'object') {
addToChecklist(option, object[option]);
}
}
};
checkGeneralSettings(this.settings);
// Check animation options
const checkAnimationSettings = object => {
for (const option in object) {
const superiorKey = option;
if (object[option] !== null) {
if (typeof object[option] !== 'object') {
addToChecklist(superiorKey, object[option]);
} else {
for (const nestedOption in object[option]) {
if (object[option][nestedOption] !== null) {
addToChecklist(superiorKey, object[option][nestedOption]);
}
}
}
}
}
};
for (const animation in this.animations) {
checkAnimationSettings(this.settings[animation]);
}
// Check breakpoint settings
if (this.settings.breakpoints !== null && typeof this.settings.breakpoints === 'object') {
for (const breakpoint in this.settings.breakpoints) {
if (!isNaN(breakpoint)) {
checkGeneralSettings(this.settings.breakpoints[breakpoint]);
for (const animation in this.animations) {
if (this.settings.breakpoints[breakpoint][animation] && typeof this.settings.breakpoints[breakpoint][animation] === 'object') {
checkAnimationSettings(this.settings.breakpoints[breakpoint][animation]);
}
}
}
}
}
// Check colors
const isColorValid = (colorString) => {
const hexRegex = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
const shortHexRegex = /^#?([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i;
const rgbRegex = /^(rgb|rgba)?\(\s*(\d+)\s*,?\s*(\d+)\s*,?\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i;
if (hexRegex.test(colorString) || shortHexRegex.test(colorString)) {
return true;
}
if (rgbRegex.test(colorString)) {
const matches = rgbRegex.exec(colorString);
const r = parseInt(matches[2], 10);
const g = parseInt(matches[3], 10);
const b = parseInt(matches[4], 10);
const a = matches[5] === undefined ? 1 : parseFloat(matches[5]);
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255 && a >= 0 && a <= 1) {
return true;
} else {
return false;
}
}
return false;
};
// Validate values
for (const category in valuesToCheck) {
for (const option in valuesToCheck[category]) {
if (category === 'strings') {
if (typeof valuesToCheck[category][option][1] === 'string') {
delete valuesToCheck[category][option];
}
}
if (category === 'arraysOrStrings') {
if (typeof valuesToCheck[category][option][1] === 'string') {
delete valuesToCheck[category][option];
} else if (Array.isArray(valuesToCheck[category][option][1])) {
let stringValues = true;
valuesToCheck[category][option][1].forEach(value => {
if (typeof value !== 'string') {
stringValues = false;
}
});
if (stringValues) {
delete valuesToCheck[category][option];
}
}
}
if (category === 'colors') {
if (isColorValid(valuesToCheck[category][option][1])) {
delete valuesToCheck[category][option];
}
}
if (category === 'numbers') {
if (!isNaN(valuesToCheck[category][option][1])) {
delete valuesToCheck[category][option];
}
}
if (category === 'relativeNumbers') {
if (!isNaN(valuesToCheck[category][option][1]) && valuesToCheck[category][option][1] >= 0 && valuesToCheck[category][option][1] <= 1) {
delete valuesToCheck[category][option];
}
}
if (category === 'booleans') {
if (typeof valuesToCheck[category][option][1] === 'boolean') {
delete valuesToCheck[category][option];
}
}
if (category === 'callbacks') {
if (typeof valuesToCheck[category][option][1] === 'function') {
delete valuesToCheck[category][option];
}
}
}
// Generate error message
if (Object.keys(category).length > 0) {
for (const option in valuesToCheck[category]) {
const error = `'${valuesToCheck[category][option][1]}' is no valid value for '${valuesToCheck[category][option][0]}'.`;
errorMessage.push(error);
}
}
}
// Throw error if invalid options/values found
if (errorMessage.length > 0) {
throw new Error(`Error: ${errorMessage.length} invalid ${errorMessage.length > 1 ? 'options' : 'option'} were found: ${errorMessage.join(' ')}`);
}
}
/**
* Retrieves animation targets and stores them in corresponding window variables.
* @returns {void}
*/
getAnimationTargets() {
for (const animation in this.animations) {
const targetSuffixes = Array.isArray(this.settings[animation].targetSuffix) ? this.settings[animation].targetSuffix : [this.settings[animation].targetSuffix];
const targetElements = this.settings[animation].globalTarget ? document : this.$root;
this[`${animation}Targets`] = targetSuffixes.flatMap(suffix =>
Array.from(targetElements.querySelectorAll(`[${this.settings.initAttr}='${suffix}']`))
);
}
}
/**
* Determines the initial styles of the target elements for each animation.
* Stores the styles in this.initialCssProperties object.
* @param {string} animation - Array with names of the animations to retrieve initial styles for.
* @returns {void}
*/
getInitialStyles() {
for (const animation in this.animations) {
const targetsProperties = {};
this[`${animation}Targets`].forEach((target, index) => {
const targetProperties = {};
const properties = window.getComputedStyle(target);
// Check if the target element has the specified property and store the property and its value
Object.entries(this.settings[animation]).forEach(([property]) => {
if (Object.prototype.hasOwnProperty.call(properties, property)) {
targetProperties[property] = properties[property];
}
});
// Store the properties object of the target element to the current animation object
targetsProperties[index] = targetProperties;
});
// Store the animation objects to initial CSS properties object
this.initialCssProperties[animation] = targetsProperties;
}
}
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
//* **********************************************
// ANIMATION CONFIGURATION
/**
* Configures animation settings.
* @returns {void}
*/
prepareAnimation() {
// Create animation objects for each defined animation
for (const animation in this.settings) {
if (animation.toLowerCase().includes('animate')) {
this.animations[animation] = {};
}
}
// Merge breakpoint options with settings if available
if (this.settings.breakpoints !== null) {
this.mergeBreakpointOptions();
}
// Gather animation targets and initial styles
this.getAnimationTargets();
this.getInitialStyles();
// Define animation frames for each defined animation
for (const animation in this.animations) {
this.defineAnimations(animation);
}
// Optimize animation performance and validate options
this.optimizeAnimationPerformance();
this.checkValidity();
// Prepare aniamtion area and observer settings
this.defineAnimationArea();
this.defineObserverOptions();
// Determine if onscroll actions are required
this.onscroll = !!(typeof this.settings.onScroll === 'function' || Object.keys(this.animations).some(animation => this.settings[animation].onscroll || this.settings[animation].devMode));
for (const animation in this.animations) {
this[`${animation}Targets`].forEach((target, index) => {
// If animation targets should be sticky, create sticky containers
if (this.settings[animation].sticky) {
if (!this.stickyAnimations.includes(animation)) {
this.stickyAnimations.push(animation);
}
this.setSticky(target, true);
}
const transitionPromises = [];
// If duration is defined and animation is of type 'onscroll', apply transition
if (this.settings[animation].duration !== null && this.settings[animation].onscroll) {
window.setTimeout(() => {
transitionPromises.push(this.setTransition(animation, target, index));
}, 400);
}
});
}
}
/**
* Defines the options for the Intersection Observer used to track trigger visibility.
* Calculates the rootMargin and threshold values.
* @returns {void}
*/
defineObserverOptions() {
// Calculate top and bottom margins for the Intersection Observer rootMargin
const topMargin = this.top[1] === 'px' ? this.top[0] * -1 : (this.top[0] * -1 / this.viewportHeight) * 100;
const bottomMargin = this.bottom[1] === 'px' ? this.bottom[0] : (this.bottom[0] / this.viewportHeight) * 100;
// Set rootMargin and threshold options for Intersection Observer
this.rootMargin = `${topMargin}${this.top[1]} 100% ${bottomMargin}${this.bottom[1]} 100%`;
this.observerOptions = {
rootMargin: this.rootMargin,
threshold: this.settings.triggerPosition
};
}
/**
* Optimizes animation performance by applying 'will-change' with relevant properties to each target.
* @returns {void}
*/
optimizeAnimationPerformance() {
// Convert a camelCase string to kebab-case
const camelCaseToDashCase = (camelCaseString) => {
return camelCaseString.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
};
for (const animation in this.animations) {
if (Object.entries(this.animations[animation]).length > 0 && this.settings[animation].improvePerformance) {
const willChangeProperties = [];
for (const property in this.settings[animation]) {
if (this.settings[animation][property] !== null) {
// Check if property is related to transform and add transform to will-change array
if (this.cssProperties.transform.indexOf(property) !== -1 && willChangeProperties.indexOf('transform') === -1) {
willChangeProperties.push('transform');
}
// Check if property is related to colors or opacity and add to will-change array
if (this.cssProperties.colors.indexOf(property) !== -1 || property === 'opacity') {
const modifiedProperty = camelCaseToDashCase(property);
willChangeProperties.push(modifiedProperty);
}
}
}
// Apply will-change to target with relevant properties
if (willChangeProperties.length > 0) {
this[`${animation}Targets`].forEach(target => {
target.style.willChange = willChangeProperties.join(', ');
});
}
}
}
}
/**
* Calculates the range where the animation is active while scrolling and stores the value in this.animationArea.
* Stores the boundaries and its units of the animation area as arrays in this.top and this.bottom.
* Optionally sets the animation area value as a CSS variable on the root element.
* @returns {void}
*/
defineAnimationArea() {
this.animationArea = null;
for (const key in this.settings) {
// Check if the key is 'top' or 'bottom'
if ((key === 'top' || key === 'bottom') && typeof this.settings[key] === 'string') {
const value = this.settings[key];
const regex = /^(-?\d+(\.\d+)?)(.+)$/;
const matches = value.match(regex);
// Check if the value matches the expected format
if (matches && matches.length === 4) {
const number = parseFloat(matches[1]);
const unit = matches[3].trim();
// Check if the parsed number is valid
if (!isNaN(number)) {
// Calculate and assign the values based on the unit
this[key][1] = unit;
this[key][0] = unit === '%' ? this.viewportHeight * number / 100 : number;
}
}
}
}
// Calculate the animation area based on various properties
this.animationArea = this.viewportHeight + this.top[0] * -1 + this.bottom[0] + this.$trigger.clientHeight - this.$trigger.clientHeight * 2 * this.settings.triggerPosition;
// Send value of this.animationArea as CSS variable to the root element
if (typeof this.settings.cssSpace === 'string') {
this.$root.style.setProperty(`--${this.settings.cssSpace}`, `${this.animationArea}px`);
}
}
/**
* Define and prepare animations for CSS-based animations.
*
* @param {string} animation - The name of the animation that has to be defined.
* @returns {void}
*/
defineAnimations(animation) {
const animationState = this.animations[animation].currentState;
const alreadyExists = !!this.animations[animation]?.[0];
// Create a copy of the original animation settings as animation template
let animationTemplate = this.deepCopy(this.settings[animation]);
// Organize the animation template for CSS animations: remove unused entries and structure property values for upcoming animations
for (const option in animationTemplate) {
// Remove entries that aren't valid CSS properties, aren't currently defined or were previously defined but no longer exist (important for updates)
if ((!Object.values(this.cssProperties).some(categoryArray => categoryArray.includes(option)) ||
(animationTemplate[option] === null && ((alreadyExists && this.animations[animation][0][option] === undefined) || !alreadyExists)))
) {
delete animationTemplate[option];
} else if (typeof animationTemplate[option] === 'object') {
// If property entry is an object
if (animationTemplate[option] !== null) {
for (const entry in animationTemplate[option]) {
// Delete entries that are no animation steps
if (isNaN(entry) || entry < 0 || entry > 100) {
delete animationTemplate[option][entry];
}
}
// Delete entries that have only one step defined
if (Object.entries(animationTemplate[option]).length <= 1) {
delete animationTemplate[option];
} else {
// If start/end value is missing define default values
if (animationTemplate[option][0] === undefined) {
const firstEntry = Object.entries(animationTemplate[option])[0][1];
animationTemplate[option][0] = firstEntry;
}
if (animationTemplate[option][100] === undefined) {
const entries = Object.entries(animationTemplate[option]);
const lastEntry = entries[entries.length - 1][1];
animationTemplate[option][100] = lastEntry;
}
}
} else {
// If the property value is null (e.g., after the property has been removed using the update method), assign default values
animationTemplate[option] = {};
animationTemplate[option][0] = null;
animationTemplate[option][100] = null;
}
} else {
// When the property entry is not an object, set its value as the target entry and establish a default starting entry
const endValue = animationTemplate[option];
animationTemplate[option] = {};
animationTemplate[option][0] = null;
animationTemplate[option][100] = endValue;
}
}
// Customize animation template for each target, replacing placeholders with the target element's initial styles
if (Object.entries(animationTemplate).length > 0) {
const transformProperties = new Set();
// When the animation type is 'keyframes', build the animation object with steps for each frame
if (!this.settings[animation].onscroll) {
const animationFramesTemplate = {};
for (const property in animationTemplate) {
// Identify transform properties
if (this.cssProperties.transform.indexOf(property) !== -1) {
transformProperties.add(property);
}
// Add each entry sorted by step and property
for (const step in animationTemplate[property]) {
if (!animationFramesTemplate[step]) {
animationFramesTemplate[step] = {};
}
animationFramesTemplate[step][property] = animationTemplate[property][step];
}
}
// Update animationTemplate with animationFramesTemplate
animationTemplate = animationFramesTemplate;
}
// Apply animation data to each target element
this[`${animation}Targets`].forEach((target, index) => {
// Clone animationTemplate for the current target
const targetAnimation = Object.fromEntries(Object.entries(animationTemplate));
// Process animation properties for the current target
for (const option in targetAnimation) {
// Replace null values with initial styles of target for animations from type 'onscroll'
if (this.settings[animation].onscroll) {
if (Object.values(targetAnimation[option]).includes(null)) {
let defaultValue;
if (this.cssProperties.transform.indexOf(option) !== -1) {
defaultValue = option.includes('scale') ? 1 : 0;
} else {
defaultValue = this.initialCssProperties[animation][index][option];
}
for (const step in targetAnimation[option]) {
if (targetAnimation[option][step] === null) {
targetAnimation[option][step] = defaultValue;
}
}
}
} else {
// Replace null values with initial styles of target for animations from type 'keyframes'
for (const property in targetAnimation[option]) {
let defaultValue;
if (this.cssProperties.transform.indexOf(property) !== -1) {
defaultValue = property.includes('scale') ? 1 : 0;
} else {
defaultValue = this.initialCssProperties[animation][index][property];
}
if (targetAnimation[option][property] === null) {
targetAnimation[option][property] = defaultValue;
}
}
// Check if transform property needs to be defined
const needsTransform = Array.from(transformProperties).some(key => Object.prototype.hasOwnProperty.call(targetAnimation[option], key));
// Calculate transform values for steps requiring transformations in cases where values are not specified
if (needsTransform) {
for (const property of transformProperties) {
if (targetAnimation[option][property] === undefined) {
const animationSteps = this.getAnimationSteps(targetAnimation, option, property);
const start = targetAnimation[animationSteps[0]][property];
const end = targetAnimation[animationSteps[1]][property];
if (start !== end) {
const animatedValue = this.calculateValue(start, end, null, option);
targetAnimation[option][property] = animatedValue;
} else {
targetAnimation[option][property] = end;
}
}
}
}
}
}
// Calculate transform property from single entries
if (!this.settings[animation].onscroll) {
for (const step in targetAnimation) {
for (const property in targetAnimation[step]) {
if (this.cssProperties.transform.indexOf(property) !== -1) {
if (targetAnimation[step].transform === undefined) {
targetAnimation[step].transform = this.generateTransform(targetAnimation[step]);
}
delete targetAnimation[step][property];
}
}
}
}
// Update the animations array with the prepared animation data for the current target
this.animations[animation][index] = targetAnimation;
});
// Add animation state if available
if (animationState !== undefined) {
this.animations[animation].currentState = animationState;
}
} else {
// If no animation data is present, initialize an empty object in the animations array
this.animations[animation] = {};
}
}
/**
* Compares two objects and defines updates based on the differences.
* @param {Object} obj1 - The first object for comparison.
* @param {Object} obj2 - The second object for comparison.
* @returns {void}
*/
compareAndDefineUpdates(obj1, obj2) {
const diffObj = {}; // Object to store differences
// Compare objects
const compare = (obj1, obj2, path = '') => {
for (const key in obj1) {
if (obj2 === null || !obj2 || obj2[key] === undefined) {
diffObj[path + key] = obj1[key];
} else if (typeof obj1[key] === 'object' && typeof obj2[key] === 'object') {
if (obj1[key] === null && obj2[key] !== null) {
diffObj[path + key] = obj1[key];
} else if (obj1[key] !== null && obj2[key] === null) {
diffObj[path + key] = obj1[key];
} else {
compare(obj1[key], obj2[key], path + key + '.');
}
} else if (obj1[key] !== obj2[key]) {
diffObj[path + key] = obj1[key];
}
}
};
// Define updates
const defineUpdates = (object) => {
for (const option in object) {
if (Object.prototype.hasOwnProperty.call(this.animations, option) && typeof object[option] === 'object') {
const animation = option;
for (const animationOption in object[option]) {
// Check updates for animation objects
// If sticky state should change
if (!this.requiredUpdates.stickyElements.includes(animation) && animationOption === 'sticky') {
this.requiredUpdates.stickyElements.push(animation);
}
// If css animations change
if (!this.requiredUpdates.animations.includes(animation) && (Object.values(this.cssProperties).some(categoryArray => categoryArray.includes(animationOption)))) {
this.requiredUpdates.animations.push(animation);
}
// If transition should change
if (!this.requiredUpdates.transition.includes(animation) && ['duration', 'delay', 'easing'].includes(animationOption)) {
this.requiredUpdates.transition.push(animation);
}
}
} else if (['top', 'bottom', 'triggerPosition'].includes(option)) {
this.requiredUpdates.observer = true;
} else if (option === 'cssSpace') {
this.requiredUpdates.cssSpace = true;
}
}
};
// Compare obj1 and obj2
compare(obj1, obj2);
const result = {};
// Create a structured result object based on differences
for (const key in diffObj) {
const nestedKeys = key.spl