1/** 2 * @file 3 * Solo Theme - Full Opacity Animation 4 * 5 * Provides fade-in animation effects on scroll for elements with the 6 * 'fade-inner' class. Implements smooth opacity transitions based on 7 * viewport position with performance optimizations. 8 * 9 * Filename: solo-full-opacity.js 10 * Website: https://www.flashwebcenter.com 11 * Developer: Alaa Haddad https://www.alaahaddad.com 12 * Refactored: Following Drupal JavaScript best practices 13 */ 14 15(function (Drupal, drupalSettings, once) { 16 'use strict'; 17 18 /** 19 * Solo theme opacity animation behavior. 20 * 21 * @namespace Drupal.behaviors.soloFullOpacity 22 * 23 * @prop {Function} attach 24 * Attaches the fade animation behavior to fade-inner elements. 25 * @prop {Function} detach 26 * Detaches the behavior and cleans up event listeners. 27 */ 28 Drupal.behaviors.soloFullOpacity = { 29 // Store references for cleanup 30 instances: new WeakMap(), 31 32 /** 33 * Attach the fade opacity behavior. 34 * 35 * @param {HTMLDocument|HTMLElement} context 36 * The context for attaching the behavior. 37 * @param {object} settings 38 * The drupalSettings object. 39 */ 40 attach: function (context, settings) { 41 // Skip if in Layout Builder mode 42 if (document.getElementById('layout-builder')) { 43 return; 44 } 45 46 // Process each fade-inner container only once 47 const fadeContainers = once('solo-full-opacity', '.fade-inner', context); 48 49 if (fadeContainers.length === 0) { 50 return; 51 } 52 53 // Check viewport and preferences 54 const viewportCheck = this.checkViewportAndPreferences(); 55 56 // If animations should be disabled, set full opacity and exit 57 if (viewportCheck.shouldDisable) { 58 fadeContainers.forEach((el) => { 59 el.style.opacity = '1'; 60 el.style.transition = 'none'; 61 el.classList.add('solo-full-opacity-processed'); 62 el.classList.add('solo-full-opacity-disabled'); 63 }); 64 return; 65 } 66 67 // Initialize the animation handler 68 const animationHandler = this.createAnimationHandler(fadeContainers, viewportCheck.breakpoint); 69 70 if (!animationHandler) { 71 return; 72 } 73 74 // Store the handler instance for cleanup 75 fadeContainers.forEach(element => { 76 this.instances.set(element, animationHandler); 77 }); 78 79 // Initial fade calculation 80 animationHandler.calculateFade(); 81 82 // Attach event listeners 83 window.addEventListener('scroll', animationHandler.throttledFadeCalculation, { passive: true }); 84 window.addEventListener('resize', animationHandler.debouncedRecalculate, { passive: true }); 85 window.addEventListener('load', animationHandler.debouncedRecalculate, { passive: true }); 86 87 // Add orientationchange for mobile devices 88 if ('orientationchange' in window) { 89 window.addEventListener('orientationchange', animationHandler.debouncedRecalculate, { passive: true }); 90 } 91 }, 92 93 /** 94 * Detach the behavior and clean up. 95 * 96 * @param {HTMLDocument|HTMLElement} context 97 * The context being detached. 98 * @param {object} settings 99 * The drupalSettings object. 100 * @param {string} trigger 101 * The trigger for the detach ('unload', 'move', 'serialize'). 102 */ 103 detach: function (context, settings, trigger) { 104 if (trigger === 'unload') { 105 // Find all processed elements and clean up 106 const processedElements = context.querySelectorAll('.fade-inner.solo-full-opacity-processed'); 107 108 processedElements.forEach(element => { 109 const handler = this.instances.get(element); 110 if (handler) { 111 // Remove all event listeners 112 window.removeEventListener('scroll', handler.throttledFadeCalculation); 113 window.removeEventListener('resize', handler.debouncedRecalculate); 114 window.removeEventListener('load', handler.debouncedRecalculate); 115 116 if ('orientationchange' in window) { 117 window.removeEventListener('orientationchange', handler.debouncedRecalculate); 118 } 119
120 // Cancel any pending animations 121 if (handler.animationFrame) { 122 cancelAnimationFrame(handler.animationFrame); 123 } 124 125 // Disconnect IntersectionObserver if it exists 126 if (handler.observer) { 127 handler.observer.disconnect(); 128 } 129 130 // Clean up instance reference 131 this.instances.delete(element); 132 } 133 134 // Remove processed classes 135 element.classList.remove('solo-full-opacity-processed', 'solo-full-opacity-disabled'); 136 }); 137 } 138 }, 139 140 /** 141 * Checks viewport size and user preferences. 142 * 143 * @return {object} 144 * Object containing shouldDisable flag and breakpoint value. 145 */ 146 checkViewportAndPreferences: function () { 147 // Get breakpoint with safe fallback 148 let breakpoint = 577; // default 149 150 try { 151 if (Drupal && Drupal.solo && typeof Drupal.solo.getBreakpointNumber === 'function') { 152 breakpoint = Drupal.solo.getBreakpointNumber('mn'); 153 } 154 } catch (error) { 155 // Use default if breakpoint helper fails 156 if (typeof console !== 'undefined' && console.warn) { 157 console.warn('Solo theme: Could not get breakpoint, using default', error); 158 } 159 } 160 161 // Check if viewport is narrow 162 const isNarrow = window.matchMedia 163 ? window.matchMedia(`(max-width: ${breakpoint - 1}px)`).matches 164 : (window.innerWidth < breakpoint); 165 166 // Check for reduced motion preference 167 const prefersReducedMotion = window.matchMedia && 168 window.matchMedia('(prefers-reduced-motion: reduce)').matches; 169 170 return { 171 shouldDisable: isNarrow || prefersReducedMotion, 172 breakpoint: breakpoint, 173 isNarrow: isNarrow, 174 prefersReducedMotion: prefersReducedMotion 175 }; 176 }, 177 178 /** 179 * Creates an animation handler with all necessary methods. 180 * 181 * @param {NodeList|Array} elements 182 * The elements to animate. 183 * @param {number} breakpoint 184 * The responsive breakpoint value. 185 * 186 * @return {object|null} 187 * The animation handler object or null if creation fails. 188 */ 189 createAnimationHandler: function (elements, breakpoint) { 190 try { 191 // Configuration with sensible defaults 192 const config = { 193 animationHeightRatio: drupalSettings.solo?.fullOpacity?.animationHeightRatio || 0.15, 194 fadeInDuration: drupalSettings.solo?.fullOpacity?.fadeInDuration || '1s', 195 fadeOutDuration: drupalSettings.solo?.fullOpacity?.fadeOutDuration || '0.5s', 196 throttleDelay: drupalSettings.solo?.fullOpacity?.throttleDelay || 16, // ~60fps 197 debounceDelay: drupalSettings.solo?.fullOpacity?.debounceDelay || 250, 198 useIntersectionObserver: drupalSettings.solo?.fullOpacity?.useIntersectionObserver !== false 199 }; 200 201 // Calculate animation metrics 202 let animationHeight = window.innerHeight * config.animationHeightRatio; 203 let ratio = this.calculateRatio(animationHeight); 204 let animationFrame = null; 205 206 // Convert NodeList to Array for better performance 207 const elementArray = Array.from(elements); 208 209 // Cache element data for performance 210 const elementCache = elementArray.map(element => { 211 // Set initial opacity 212 element.style.opacity = '0'; 213 element.classList.add('solo-full-opacity-processed'); 214 215 return { 216 element: element, 217 height: element.offsetHeight, 218 offset: 0, 219 isInViewport: false 220 }; 221 }); 222 223 /** 224 * Updates cached offsets for all elements. 225 */ 226 const updateOffsets = () => { 227 elementCache.forEach(item => { 228 if (item.element.isConnected) { 229 const rect = item.element.getBoundingClientRect(); 230 item.offset = rect.top + window.pageYOffset; 231 item.height = rect.height; 232 } 233 }); 234 }; 235 236 /** 237 * Calculates fade opacity for all elements. 238 */ 239 const calculateFade = () => { 240 try {
241 // Cancel any pending animation frame 242 if (animationFrame) { 243 cancelAnimationFrame(animationFrame); 244 } 245 246 const windowBottom = window.pageYOffset + window.innerHeight; 247 248 // Use requestAnimationFrame for smooth animations 249 animationFrame = requestAnimationFrame(() => { 250 const workset = (observer) 251 ? elementCache.filter(i => i.isInViewport) 252 : elementCache; 253 workset.forEach(item => { 254 if (!item.element.isConnected) { 255 return; // Skip if element is no longer in DOM 256 } 257 258 const opacity = this.calculateOpacity( 259 item.offset, 260 windowBottom, 261 animationHeight, 262 ratio 263 ); 264 265 const transition = opacity === 1 266 ? `opacity ${config.fadeInDuration} linear` 267 : `opacity ${config.fadeOutDuration} linear`; 268 269 // Only update if values have changed to avoid reflows 270 if (item.element.style.opacity !== String(opacity)) { 271 if (item.element.style.transition !== transition) { 272 item.element.style.transition = transition; 273 } 274 item.element.style.opacity = String(opacity); 275 } 276 }); 277 }); 278 } catch (error) { 279 if (typeof console !== 'undefined' && console.error) { 280 console.error('Solo theme: Error calculating fade opacity', error); 281 } 282 } 283 }; 284 285 /** 286 * Checks if animations should be disabled and handles the transition. 287 */ 288 const checkAndToggleAnimations = () => { 289 const viewportCheck = this.checkViewportAndPreferences(); 290 291 if (viewportCheck.shouldDisable) { 292 // Disable animations - set all elements to full opacity 293 elementCache.forEach(item => { 294 if (item.element.isConnected) { 295 item.element.style.transition = 'opacity 0.3s ease'; 296 item.element.style.opacity = '1'; 297 item.element.classList.add('solo-full-opacity-disabled'); 298 } 299 }); 300 return false; // Animations disabled 301 } else { 302 // Re-enable animations if they were disabled 303 elementCache.forEach(item => { 304 if (item.element.isConnected && item.element.classList.contains('solo-full-opacity-disabled')) { 305 item.element.classList.remove('solo-full-opacity-disabled'); 306 } 307 }); 308 return true; // Animations enabled 309 } 310 }; 311 312 /** 313 * Recalculates animation metrics on resize. 314 */ 315 const recalculate = () => { 316 // Check if animations should be active 317 if (!checkAndToggleAnimations()) { 318 return; // Exit if animations are disabled 319 } 320 321 animationHeight = window.innerHeight * config.animationHeightRatio; 322 ratio = this.calculateRatio(animationHeight); 323 updateOffsets(); 324 calculateFade(); 325 }; 326 327 // Create throttled and debounced versions 328 const throttledFadeCalculation = this.throttle(calculateFade, config.throttleDelay); 329 330 const debouncedRecalculate = this.debounce(recalculate, config.debounceDelay); 331 332 // Initial setup 333 updateOffsets(); 334 335 // Use IntersectionObserver if available and enabled 336 let observer = null; 337 if (config.useIntersectionObserver && 'IntersectionObserver' in window) { 338 try { 339 observer = new IntersectionObserver((entries) => { 340 entries.forEach(entry => { 341 const cacheItem = elementCache.find(item => item.element === entry.target); 342 if (cacheItem) { 343 cacheItem.isInViewport = entry.isIntersecting; 344 } 345 }); 346 // Only calculate fade for visible elements 347 calculateFade(); 348 }, { 349 rootMargin: `${animationHeight}px 0px ${animationHeight}px 0px`, 350 threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] 351 }); 352 353 // Observe all elements 354 elementCache.forEach(item => { 355 observer.observe(item.element); 356 }); 357 } catch (error) { 358 // Fallback to scroll-based detection 359 observer = null; 360 } 361 } 362 363 return { 364 calculateFade: calculateFade, 365 throttledFadeCalculation: throttledFadeCalculation, 366 debouncedRecalculate: debouncedRecalculate, 367 updateOffsets: updateOffsets, 368 animationFrame: animationFrame, 369 observer: observer 370 }; 371 372 } catch (error) { 373 if (typeof console !== 'undefined' && console.error) { 374 console.error('Solo theme: Failed to create animation handler', error); 375 } 376 return null; 377 } 378 }, 379 380 /** 381 * Calculates the ratio for opacity transitions. 382 * 383 * @param {number} animationHeight 384 * The height of the animation zone. 385 * 386 * @return {number} 387 * The calculated ratio. 388 */ 389 calculateRatio: function (animationHeight) { 390 // Prevent division by zero 391 if (animationHeight <= 0) { 392 return 0; 393 } 394 return Math.round((1 / animationHeight) * 10000) / 10000; 395 }, 396 397 /** 398 * Calculates opacity based on element position. 399 * 400 * @param {number} objectTop 401 * The top position of the object. 402 * @param {number} windowBottom 403 * The bottom position of the viewport. 404 * @param {number} animationHeight 405 * The height of the animation zone. 406 * @param {number} ratio 407 * The opacity change ratio. 408 * 409 * @return {number} 410 * The calculated opacity (0-1). 411 */ 412 calculateOpacity: function (objectTop, windowBottom, animationHeight, ratio) { 413 // Element is above the animation zone - fully visible 414 if (objectTop < windowBottom - animationHeight) { 415 return 1; 416 } 417 418 // Element is below the viewport - hidden 419 if (objectTop >= windowBottom) { 420 return 0; 421 } 422 423 // Element is in the animation zone - calculate opacity 424 const opacity = (windowBottom - objectTop) * ratio; 425 426 // Clamp between 0 and 1 427 return Math.max(0, Math.min(1, opacity)); 428 }, 429 430 /** 431 * Throttle function to limit execution frequency. 432 * 433 * @param {Function} func 434 * The function to throttle. 435 * @param {number} delay 436 * The throttle delay in milliseconds. 437 * 438 * @return {Function} 439 * The throttled function. 440 */ 441 throttle: function (func, delay) { 442 let timeoutId; 443 let lastExecTime = 0; 444 445 return function throttled() { 446 const context = this; 447 const args = arguments; 448 const currentTime = Date.now(); 449 450 const execute = () => { 451 lastExecTime = Date.now();
452 func.apply(context, args); 453 }; 454 455 const remaining = delay - (currentTime - lastExecTime); 456 457 clearTimeout(timeoutId); 458 459 if (remaining <= 0 || remaining > delay) { 460 execute(); 461 } else { 462 timeoutId = setTimeout(execute, remaining); 463 } 464 }; 465 }, 466 467 /** 468 * Debounce function to delay execution until after wait period. 469 * 470 * @param {Function} func 471 * The function to debounce. 472 * @param {number} wait 473 * The debounce delay in milliseconds. 474 * 475 * @return {Function} 476 * The debounced function. 477 */ 478 debounce: function (func, wait) { 479 let timeoutId; 480 481 return function debounced() { 482 const context = this; 483 const args = arguments; 484 485 clearTimeout(timeoutId); 486 487 timeoutId = setTimeout(() => { 488 func.apply(context, args); 489 }, wait); 490 }; 491 } 492 }; 493 494})(Drupal, drupalSettings, once);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.