1/** 2 * MR Blocks Animation Frontend Engine 3 * Phase 4: Complete animation system with Intersection Observer, hover handlers, and performance optimization 4 */ 5 6class MRAnimationEngine { 7 constructor() { 8 this.observers = new Map(); 9 this.animatedElements = new Set(); 10 this.isInitialized = false; 11 this.isReducedMotion = false; 12 this.isMobile = false; 13 this.debugMode = false; 14 15 // Performance tracking 16 this.performanceMetrics = { 17 elementsProcessed: 0, 18 animationsTriggered: 0, 19 startTime: performance.now(), 20 }; 21 22 // Bind methods 23 this.handleIntersection = this.handleIntersection.bind(this); 24 this.handleResize = this.debounce(this.handleResize.bind(this), 250); 25 this.cleanup = this.cleanup.bind(this); 26 } 27 28 /** 29 * Initialize the animation engine 30 */ 31 init() { 32 if (this.isInitialized) return; 33 34 this.log("Initializing MR Blocks Animation Engine"); 35 36 // Check for reduced motion preference 37 this.checkReducedMotion(); 38 39 // Detect mobile devices 40 this.detectMobile(); 41 42 // Find and process animated elements 43 this.findAnimatedElements(); 44 45 // Setup intersection observers 46 this.setupIntersectionObservers(); 47 48 // Setup hover handlers 49 this.setupHoverHandlers(); 50 51 // Setup event listeners 52 this.setupEventListeners(); 53 54 this.isInitialized = true; 55 this.log( 56 `Initialized with ${this.animatedElements.size} animated elements`, 57 ); 58 } 59 60 /** 61 * Check if user prefers reduced motion 62 */ 63 checkReducedMotion() { 64 this.isReducedMotion = 65 window.matchMedia && 66 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 67 68 if (this.isReducedMotion) { 69 this.log("Reduced motion detected - animations will be minimized"); 70 document.documentElement.classList.add("mr-anim-reduced-motion"); 71 } 72 } 73 74 /** 75 * Detect mobile devices 76 */ 77 detectMobile() { 78 this.isMobile = 79 /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( 80 navigator.userAgent, 81 ) || window.innerWidth <= 768; 82 83 if (this.isMobile) { 84 this.log("Mobile device detected"); 85 document.documentElement.classList.add("mr-anim-mobile"); 86 } 87 } 88 89 /** 90 * Find all animated elements on the page 91 */ 92 findAnimatedElements() { 93 const elements = document.querySelectorAll(".animated"); 94 95 elements.forEach((element) => { 96 if (!this.animatedElements.has(element)) { 97 this.processAnimatedElement(element); 98 this.animatedElements.add(element); 99 this.performanceMetrics.elementsProcessed++; 100 } 101 }); 102 } 103 104 /** 105 * Process individual animated element 106 */ 107 processAnimatedElement(element) { 108 // Parse animation settings from classes 109 const settings = this.parseAnimationSettings(element); 110 111 // Store settings on element 112 element._mrAnimSettings = settings; 113 114 // Hide element initially if not hover animation 115 if (!settings.isHover && !this.isReducedMotion) { 116 element.classList.add("mr-anim-hidden"); 117 } 118 119 this.log("Processed element:", settings); 120 } 121 122 /** 123 * Parse animation settings from element classes 124 */ 125 parseAnimationSettings(element) { 126 const classes = element.className; 127 128 // Extract animation type 129 const animationTypes = [ 130 "bounce", 131 "flash", 132 "pulse", 133 "rubberBand", 134 "shakeX", 135 "shakeY", 136 "headShake", 137 "swing", 138 "tada", 139 "wobble", 140 "jello", 141 "heartBeat", 142 "fadeIn", 143 "fadeInDown", 144 "fadeInLeft", 145 "fadeInRight", 146 "fadeInUp", 147 "fadeInDownBig", 148 "fadeInLeftBig", 149 "fadeInRightBig", 150 "fadeInUpBig", 151 "bounceIn", 152 "bounceInDown", 153 "bounceInLeft", 154 "bounceInRight", 155 "bounceInUp", 156 "slideInDown", 157 "slideInLeft", 158 "slideInRight", 159 "slideInUp", 160 "zoomIn", 161 "zoomInDown", 162 "zoomInLeft", 163 "zoomInRight", 164 "zoomInUp", 165 "backInDown", 166 "backInLeft", 167 "backInRight", 168 "backInUp", 169 "rotateIn", 170 "rotateInDownLeft", 171 "rotateInDownRight", 172 "rotateInUpLeft", 173 "rotateInUpRight", 174 "fadeOut", 175 "fadeOutDown", 176 "fadeOutLeft", 177 "fadeOutRight", 178 "fadeOutUp", 179 "bounceOut", 180 "bounceOutDown", 181 "bounceOutLeft", 182 "bounceOutRight", 183 "bounceOutUp", 184 "slideOutDown", 185 "slideOutLeft", 186 "slideOutRight", 187 "slideOutUp", 188 "zoomOut", 189 "zoomOutDown", 190 "zoomOutLeft", 191 "zoomOutRight", 192 "zoomOutUp", 193 "flip", 194 "flipInX", 195 "flipInY", 196 "flipOutX", 197 "flipOutY", 198 "lightSpeedInRight", 199 "lightSpeedInLeft", 200 "lightSpeedOutRight", 201 "lightSpeedOutLeft", 202 "hinge", 203 "jackInTheBox", 204 "rollIn", 205 "rollOut", 206 ]; 207 208 const animationType = animationTypes.find((type) => classes.includes(type)); 209 210 // Extract speed 211 let speed = "default"; 212 if (classes.includes("slower")) speed = "slower"; 213 else if (classes.includes("slow")) speed = "slow"; 214 else if (classes.includes("faster")) speed = "faster"; 215 else if (classes.includes("fast")) speed = "fast"; 216 217 // Extract delay 218 const delayMatch = classes.match( 219 /mr-anim-value-delay-(\d+(?:\.\d+)?)(ms|s)/, 220 );
221 const delay = delayMatch ? delayMatch[0] : null; 222 223 // Check if hover animation 224 const isHover = classes.includes("mr-anim-hover"); 225 226 // Extract trigger offset 227 const offsetMatch = classes.match(/mr-anim-offset-(\d+)%/); 228 const triggerOffset = offsetMatch ? parseInt(offsetMatch[1]) / 100 : 0.1; // Default 10% 229 230 // Check for repeat 231 const isRepeat = classes.includes("repeat") || classes.includes("infinite"); 232 233 return { 234 animationType, 235 speed, 236 delay, 237 isHover, 238 triggerOffset, 239 isRepeat, 240 element, 241 hasTriggered: false, 242 }; 243 } 244 245 /** 246 * Setup Intersection Observers for scroll-triggered animations 247 */ 248 setupIntersectionObservers() { 249 if (!window.IntersectionObserver) { 250 this.log( 251 "IntersectionObserver not supported, falling back to immediate trigger", 252 ); 253 this.fallbackToImmediateTrigger(); 254 return; 255 } 256 257 // Group elements by trigger offset for efficiency 258 const offsetGroups = new Map(); 259 260 this.animatedElements.forEach((element) => { 261 const settings = element._mrAnimSettings; 262 if (!settings.isHover) { 263 const offset = settings.triggerOffset; 264 if (!offsetGroups.has(offset)) { 265 offsetGroups.set(offset, []); 266 } 267 offsetGroups.get(offset).push(element); 268 } 269 }); 270 271 // Create observer for each offset group 272 offsetGroups.forEach((elements, offset) => { 273 const observer = new IntersectionObserver(this.handleIntersection, { 274 threshold: offset, 275 rootMargin: "0px", 276 }); 277 278 elements.forEach((element) => { 279 observer.observe(element); 280 }); 281 282 this.observers.set(offset, observer); 283 this.log( 284 `Created observer for offset ${offset * 100}% with ${ 285 elements.length 286 } elements`, 287 ); 288 }); 289 } 290 291 /** 292 * Handle intersection observer callbacks 293 */ 294 handleIntersection(entries) { 295 entries.forEach((entry) => { 296 if (entry.isIntersecting) { 297 const element = entry.target; 298 const settings = element._mrAnimSettings; 299 300 if (!settings.hasTriggered || settings.isRepeat) { 301 this.triggerAnimation(element, settings); 302 settings.hasTriggered = true; 303 this.performanceMetrics.animationsTriggered++; 304 305 // Stop observing if not repeating 306 if (!settings.isRepeat) { 307 entry.target._observer?.unobserve(entry.target); 308 } 309 } 310 } 311 }); 312 } 313 314 /** 315 * Setup hover handlers for hover animations 316 */ 317 setupHoverHandlers() { 318 this.animatedElements.forEach((element) => { 319 const settings = element._mrAnimSettings; 320 321 if (settings.isHover) { 322 // Remove hidden class for hover animations 323 element.classList.remove("mr-anim-hidden"); 324 325 if (this.isMobile) { 326 // On mobile, use touch events 327 this.setupMobileHoverAlternative(element, settings); 328 } else { 329 // Desktop hover events 330 element.addEventListener("mouseenter", () => { 331 this.triggerAnimation(element, settings); 332 }); 333 334 element.addEventListener("mouseleave", () => { 335 this.resetAnimation(element, settings); 336 }); 337 } 338 } 339 }); 340 } 341 342 /** 343 * Setup mobile hover alternatives 344 */ 345 setupMobileHoverAlternative(element, settings) { 346 // On mobile, trigger animation on first touch/tap 347 const handleTouch = () => { 348 this.triggerAnimation(element, settings); 349 element.removeEventListener("touchstart", handleTouch); 350 }; 351 352 element.addEventListener("touchstart", handleTouch, { passive: true }); 353 } 354 355 /** 356 * Trigger animation on element 357 */ 358 triggerAnimation(element, settings) { 359 if (this.isReducedMotion) { 360 // For reduced motion, just show the element 361 element.classList.add("mr-anim-ready"); 362 element.classList.remove("mr-anim-hidden"); 363 return; 364 } 365 366 // Remove hidden class and add ready class 367 element.classList.remove("mr-anim-hidden"); 368 element.classList.add("mr-anim-ready"); 369 370 // Apply delay if specified 371 if (settings.delay) { 372 element.style.animationDelay = settings.delay.replace( 373 "mr-anim-value-delay-", 374 "", 375 ); 376 } 377 378 // Apply speed class 379 if (settings.speed !== "default") { 380 element.classList.add(settings.speed); 381 } 382 383 this.log(`Triggered ${settings.animationType} animation on element`); 384 385 // Dispatch custom event 386 element.dispatchEvent( 387 new CustomEvent("mrAnimationTriggered", { 388 detail: { settings }, 389 }), 390 ); 391 } 392 393 /** 394 * Reset animation (for hover animations) 395 */ 396 resetAnimation(element, settings) { 397 if (settings.isHover && !this.isReducedMotion) { 398 // Force reflow to restart animation 399 element.style.animation = "none"; 400 element.offsetHeight; // Trigger reflow 401 element.style.animation = null; 402 } 403 } 404
405 /** 406 * Fallback for browsers without IntersectionObserver 407 */ 408 fallbackToImmediateTrigger() { 409 this.animatedElements.forEach((element) => { 410 const settings = element._mrAnimSettings; 411 if (!settings.isHover) { 412 setTimeout(() => { 413 this.triggerAnimation(element, settings); 414 }, 100); 415 } 416 }); 417 } 418 419 /** 420 * Setup event listeners 421 */ 422 setupEventListeners() { 423 // Handle window resize 424 window.addEventListener("resize", this.handleResize); 425 426 // Handle page visibility changes 427 document.addEventListener("visibilitychange", () => { 428 if (document.hidden) { 429 this.pauseAnimations(); 430 } else { 431 this.resumeAnimations(); 432 } 433 }); 434 435 // Handle page unload 436 window.addEventListener("beforeunload", this.cleanup); 437 438 // Listen for reduced motion changes 439 if (window.matchMedia) { 440 const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); 441 mediaQuery.addEventListener("change", () => { 442 this.checkReducedMotion(); 443 this.refreshAnimations(); 444 }); 445 } 446 } 447 448 /** 449 * Handle window resize 450 */ 451 handleResize() { 452 const wasMobile = this.isMobile; 453 this.detectMobile(); 454 455 if (wasMobile !== this.isMobile) { 456 this.log("Device type changed, refreshing animations"); 457 this.refreshAnimations(); 458 } 459 } 460 461 /** 462 * Pause animations (when page is hidden) 463 */ 464 pauseAnimations() { 465 document.documentElement.classList.add("mr-anim-paused"); 466 } 467 468 /** 469 * Resume animations (when page becomes visible) 470 */ 471 resumeAnimations() { 472 document.documentElement.classList.remove("mr-anim-paused"); 473 } 474 475 /** 476 * Refresh animations (useful for dynamic content) 477 */ 478 refreshAnimations() { 479 this.log("Refreshing animations"); 480 481 // Find new animated elements 482 this.findAnimatedElements(); 483 484 // Cleanup old observers 485 this.observers.forEach((observer) => observer.disconnect()); 486 this.observers.clear(); 487 488 // Re-setup observers 489 this.setupIntersectionObservers(); 490 this.setupHoverHandlers(); 491 } 492 493 /** 494 * Cleanup resources 495 */ 496 cleanup() { 497 this.log("Cleaning up animation engine"); 498 499 this.observers.forEach((observer) => observer.disconnect()); 500 this.observers.clear(); 501 this.animatedElements.clear(); 502 503 window.removeEventListener("resize", this.handleResize); 504 window.removeEventListener("beforeunload", this.cleanup); 505 } 506 507 /** 508 * Get performance metrics 509 */ 510 getPerformanceMetrics() { 511 const currentTime = performance.now(); 512 return { 513 ...this.performanceMetrics, 514 runtime: currentTime - this.performanceMetrics.startTime, 515 isInitialized: this.isInitialized, 516 elementsActive: this.animatedElements.size, 517 observersActive: this.observers.size, 518 }; 519 } 520 521 /** 522 * Utility: Debounce function 523 */ 524 debounce(func, wait) { 525 let timeout; 526 return function executedFunction(...args) { 527 const later = () => { 528 clearTimeout(timeout); 529 func(...args); 530 }; 531 clearTimeout(timeout); 532 timeout = setTimeout(later, wait); 533 }; 534 } 535 536 /** 537 * Logging utility 538 */ 539 log(...args) { 540 if (this.debugMode || window.mrAnimationDebug) { 541 console.log("[MR Animation]", ...args); 542 } 543 } 544} 545 546// Initialize the animation engine 547let animationEngine; 548 549document.addEventListener("DOMContentLoaded", function () { 550 animationEngine = new MRAnimationEngine(); 551 animationEngine.init(); 552 553 // Expose for debugging 554 window.mrAnimation = animationEngine; 555}); 556 557// Handle dynamic content loading (for AJAX/SPA) 558if (window.MutationObserver) { 559 const observer = new MutationObserver(function (mutations) { 560 let shouldRefresh = false; 561 562 mutations.forEach(function (mutation) { 563 mutation.addedNodes.forEach(function (node) { 564 if ( 565 node.nodeType === 1 && // Element node 566 (node.classList?.contains("animated") || 567 node.querySelector?.(".animated")) 568 ) { 569 shouldRefresh = true; 570 } 571 }); 572 }); 573 574 if (shouldRefresh && animationEngine) { 575 animationEngine.refreshAnimations(); 576 } 577 }); 578 579 observer.observe(document.body, { 580 childList: true, 581 subtree: true, 582 }); 583} 584 585console.log("MR Blocks Animation Frontend Engine loaded");
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.