UNPKG

scroll-captain

Version:

Scroll Captain is a JS library for creating scroll-triggered animations.

1,027 lines (881 loc) 96.6 kB
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