- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
555 lines
18 KiB
JavaScript
555 lines
18 KiB
JavaScript
// modules/api-manager/AnimationManager.js
|
|
import { Logger } from '../../core/logger.js';
|
|
|
|
/**
|
|
* Web Animations API Manager - High-performance animations with timeline control
|
|
*/
|
|
export class AnimationManager {
|
|
constructor(config = {}) {
|
|
this.config = config;
|
|
this.activeAnimations = new Map();
|
|
this.animationGroups = new Map();
|
|
this.defaultEasing = config.easing || 'ease-out';
|
|
this.defaultDuration = config.duration || 300;
|
|
|
|
// Check for Web Animations API support
|
|
this.supported = 'animate' in Element.prototype;
|
|
|
|
if (!this.supported) {
|
|
Logger.warn('[AnimationManager] Web Animations API not supported, using fallbacks');
|
|
} else {
|
|
Logger.info('[AnimationManager] Initialized with Web Animations API support');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Animate element using Web Animations API
|
|
*/
|
|
animate(element, keyframes, options = {}) {
|
|
if (!element || !keyframes) {
|
|
Logger.warn('[AnimationManager] Missing element or keyframes');
|
|
return null;
|
|
}
|
|
|
|
const animationOptions = {
|
|
duration: this.defaultDuration,
|
|
easing: this.defaultEasing,
|
|
fill: 'forwards',
|
|
...options
|
|
};
|
|
|
|
const animationId = this.generateId();
|
|
|
|
if (this.supported) {
|
|
const animation = element.animate(keyframes, animationOptions);
|
|
|
|
// Enhanced animation object
|
|
const enhancedAnimation = this.enhanceAnimation(animation, animationId, {
|
|
element,
|
|
keyframes,
|
|
options: animationOptions
|
|
});
|
|
|
|
this.activeAnimations.set(animationId, enhancedAnimation);
|
|
|
|
// Auto-cleanup on finish
|
|
animation.addEventListener('finish', () => {
|
|
this.activeAnimations.delete(animationId);
|
|
});
|
|
|
|
Logger.info(`[AnimationManager] Animation started: ${animationId}`);
|
|
return enhancedAnimation;
|
|
} else {
|
|
return this.fallbackAnimate(element, keyframes, animationOptions, animationId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create keyframe animations
|
|
*/
|
|
keyframes(keyframeSet) {
|
|
// Convert CSS-like keyframes to Web Animations format
|
|
if (Array.isArray(keyframeSet)) {
|
|
return keyframeSet;
|
|
}
|
|
|
|
// Handle object format: { '0%': {...}, '50%': {...}, '100%': {...} }
|
|
if (typeof keyframeSet === 'object') {
|
|
const frames = [];
|
|
const sortedKeys = Object.keys(keyframeSet).sort((a, b) => {
|
|
const aPercent = parseFloat(a.replace('%', ''));
|
|
const bPercent = parseFloat(b.replace('%', ''));
|
|
return aPercent - bPercent;
|
|
});
|
|
|
|
sortedKeys.forEach(key => {
|
|
const offset = parseFloat(key.replace('%', '')) / 100;
|
|
frames.push({
|
|
...keyframeSet[key],
|
|
offset
|
|
});
|
|
});
|
|
|
|
return frames;
|
|
}
|
|
|
|
return keyframeSet;
|
|
}
|
|
|
|
/**
|
|
* Pre-defined animation effects
|
|
*/
|
|
effects = {
|
|
// Entrance animations
|
|
fadeIn: (duration = 300) => ([
|
|
{ opacity: 0 },
|
|
{ opacity: 1 }
|
|
]),
|
|
|
|
slideInLeft: (distance = '100px', duration = 300) => ([
|
|
{ transform: `translateX(-${distance})`, opacity: 0 },
|
|
{ transform: 'translateX(0)', opacity: 1 }
|
|
]),
|
|
|
|
slideInRight: (distance = '100px', duration = 300) => ([
|
|
{ transform: `translateX(${distance})`, opacity: 0 },
|
|
{ transform: 'translateX(0)', opacity: 1 }
|
|
]),
|
|
|
|
slideInUp: (distance = '100px', duration = 300) => ([
|
|
{ transform: `translateY(${distance})`, opacity: 0 },
|
|
{ transform: 'translateY(0)', opacity: 1 }
|
|
]),
|
|
|
|
slideInDown: (distance = '100px', duration = 300) => ([
|
|
{ transform: `translateY(-${distance})`, opacity: 0 },
|
|
{ transform: 'translateY(0)', opacity: 1 }
|
|
]),
|
|
|
|
scaleIn: (scale = 0.8, duration = 300) => ([
|
|
{ transform: `scale(${scale})`, opacity: 0 },
|
|
{ transform: 'scale(1)', opacity: 1 }
|
|
]),
|
|
|
|
rotateIn: (rotation = '-180deg', duration = 600) => ([
|
|
{ transform: `rotate(${rotation})`, opacity: 0 },
|
|
{ transform: 'rotate(0deg)', opacity: 1 }
|
|
]),
|
|
|
|
// Exit animations
|
|
fadeOut: (duration = 300) => ([
|
|
{ opacity: 1 },
|
|
{ opacity: 0 }
|
|
]),
|
|
|
|
slideOutLeft: (distance = '100px', duration = 300) => ([
|
|
{ transform: 'translateX(0)', opacity: 1 },
|
|
{ transform: `translateX(-${distance})`, opacity: 0 }
|
|
]),
|
|
|
|
slideOutRight: (distance = '100px', duration = 300) => ([
|
|
{ transform: 'translateX(0)', opacity: 1 },
|
|
{ transform: `translateX(${distance})`, opacity: 0 }
|
|
]),
|
|
|
|
scaleOut: (scale = 0.8, duration = 300) => ([
|
|
{ transform: 'scale(1)', opacity: 1 },
|
|
{ transform: `scale(${scale})`, opacity: 0 }
|
|
]),
|
|
|
|
// Attention seekers
|
|
bounce: (intensity = '20px', duration = 600) => ([
|
|
{ transform: 'translateY(0)' },
|
|
{ transform: `translateY(-${intensity})`, offset: 0.25 },
|
|
{ transform: 'translateY(0)', offset: 0.5 },
|
|
{ transform: `translateY(-${intensity})`, offset: 0.75 },
|
|
{ transform: 'translateY(0)' }
|
|
]),
|
|
|
|
pulse: (scale = 1.1, duration = 600) => ([
|
|
{ transform: 'scale(1)' },
|
|
{ transform: `scale(${scale})`, offset: 0.5 },
|
|
{ transform: 'scale(1)' }
|
|
]),
|
|
|
|
shake: (distance = '10px', duration = 600) => ([
|
|
{ transform: 'translateX(0)' },
|
|
{ transform: `translateX(-${distance})`, offset: 0.1 },
|
|
{ transform: `translateX(${distance})`, offset: 0.2 },
|
|
{ transform: `translateX(-${distance})`, offset: 0.3 },
|
|
{ transform: `translateX(${distance})`, offset: 0.4 },
|
|
{ transform: `translateX(-${distance})`, offset: 0.5 },
|
|
{ transform: `translateX(${distance})`, offset: 0.6 },
|
|
{ transform: `translateX(-${distance})`, offset: 0.7 },
|
|
{ transform: `translateX(${distance})`, offset: 0.8 },
|
|
{ transform: `translateX(-${distance})`, offset: 0.9 },
|
|
{ transform: 'translateX(0)' }
|
|
]),
|
|
|
|
rubberBand: (duration = 1000) => ([
|
|
{ transform: 'scale(1)' },
|
|
{ transform: 'scale(1.25, 0.75)', offset: 0.3 },
|
|
{ transform: 'scale(0.75, 1.25)', offset: 0.4 },
|
|
{ transform: 'scale(1.15, 0.85)', offset: 0.5 },
|
|
{ transform: 'scale(0.95, 1.05)', offset: 0.65 },
|
|
{ transform: 'scale(1.05, 0.95)', offset: 0.75 },
|
|
{ transform: 'scale(1)' }
|
|
]),
|
|
|
|
// Loading animations
|
|
spin: (duration = 1000) => ([
|
|
{ transform: 'rotate(0deg)' },
|
|
{ transform: 'rotate(360deg)' }
|
|
]),
|
|
|
|
heartbeat: (scale = 1.3, duration = 1000) => ([
|
|
{ transform: 'scale(1)' },
|
|
{ transform: `scale(${scale})`, offset: 0.14 },
|
|
{ transform: 'scale(1)', offset: 0.28 },
|
|
{ transform: `scale(${scale})`, offset: 0.42 },
|
|
{ transform: 'scale(1)', offset: 0.70 }
|
|
])
|
|
};
|
|
|
|
/**
|
|
* Predefined easing functions
|
|
*/
|
|
easings = {
|
|
linear: 'linear',
|
|
ease: 'ease',
|
|
easeIn: 'ease-in',
|
|
easeOut: 'ease-out',
|
|
easeInOut: 'ease-in-out',
|
|
|
|
// Custom cubic-bezier easings
|
|
easeInQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
easeOutQuad: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
|
easeInOutQuad: 'cubic-bezier(0.455, 0.03, 0.515, 0.955)',
|
|
|
|
easeInCubic: 'cubic-bezier(0.32, 0, 0.67, 0)',
|
|
easeOutCubic: 'cubic-bezier(0.33, 1, 0.68, 1)',
|
|
easeInOutCubic: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
|
|
easeInQuart: 'cubic-bezier(0.5, 0, 0.75, 0)',
|
|
easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)',
|
|
easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)',
|
|
|
|
// Bouncy easings
|
|
easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
easeInBack: 'cubic-bezier(0.36, 0, 0.66, -0.56)',
|
|
easeInOutBack: 'cubic-bezier(0.68, -0.6, 0.32, 1.6)'
|
|
};
|
|
|
|
/**
|
|
* Quick effect methods
|
|
*/
|
|
fadeIn(element, options = {}) {
|
|
return this.animate(element, this.effects.fadeIn(), {
|
|
duration: 300,
|
|
easing: this.easings.easeOut,
|
|
...options
|
|
});
|
|
}
|
|
|
|
fadeOut(element, options = {}) {
|
|
return this.animate(element, this.effects.fadeOut(), {
|
|
duration: 300,
|
|
easing: this.easings.easeIn,
|
|
...options
|
|
});
|
|
}
|
|
|
|
slideIn(element, direction = 'up', options = {}) {
|
|
const effectMap = {
|
|
up: 'slideInUp',
|
|
down: 'slideInDown',
|
|
left: 'slideInLeft',
|
|
right: 'slideInRight'
|
|
};
|
|
|
|
return this.animate(element, this.effects[effectMap[direction]](), {
|
|
duration: 400,
|
|
easing: this.easings.easeOutBack,
|
|
...options
|
|
});
|
|
}
|
|
|
|
bounce(element, options = {}) {
|
|
return this.animate(element, this.effects.bounce(), {
|
|
duration: 600,
|
|
easing: this.easings.easeInOut,
|
|
...options
|
|
});
|
|
}
|
|
|
|
pulse(element, options = {}) {
|
|
return this.animate(element, this.effects.pulse(), {
|
|
duration: 600,
|
|
easing: this.easings.easeInOut,
|
|
iterations: Infinity,
|
|
...options
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animation groups - animate multiple elements together
|
|
*/
|
|
group(animations, options = {}) {
|
|
const groupId = this.generateId('group');
|
|
const animationPromises = [];
|
|
|
|
animations.forEach(({ element, keyframes, animationOptions = {} }) => {
|
|
const animation = this.animate(element, keyframes, {
|
|
...animationOptions,
|
|
...options
|
|
});
|
|
|
|
if (animation && animation.finished) {
|
|
animationPromises.push(animation.finished);
|
|
}
|
|
});
|
|
|
|
const groupController = {
|
|
id: groupId,
|
|
finished: Promise.all(animationPromises),
|
|
play: () => {
|
|
animations.forEach(anim => {
|
|
if (anim.animation) anim.animation.play();
|
|
});
|
|
},
|
|
pause: () => {
|
|
animations.forEach(anim => {
|
|
if (anim.animation) anim.animation.pause();
|
|
});
|
|
},
|
|
reverse: () => {
|
|
animations.forEach(anim => {
|
|
if (anim.animation) anim.animation.reverse();
|
|
});
|
|
},
|
|
cancel: () => {
|
|
animations.forEach(anim => {
|
|
if (anim.animation) anim.animation.cancel();
|
|
});
|
|
}
|
|
};
|
|
|
|
this.animationGroups.set(groupId, groupController);
|
|
return groupController;
|
|
}
|
|
|
|
/**
|
|
* Staggered animations - animate elements with delay
|
|
*/
|
|
stagger(elements, keyframes, options = {}) {
|
|
const staggerDelay = options.staggerDelay || 100;
|
|
const animations = [];
|
|
|
|
elements.forEach((element, index) => {
|
|
const delay = index * staggerDelay;
|
|
const animation = this.animate(element, keyframes, {
|
|
...options,
|
|
delay
|
|
});
|
|
animations.push({ element, animation });
|
|
});
|
|
|
|
return this.group(animations.map(({ element, animation }) => ({
|
|
element,
|
|
keyframes,
|
|
animation
|
|
})));
|
|
}
|
|
|
|
/**
|
|
* Timeline animations - sequence animations
|
|
*/
|
|
timeline(sequence) {
|
|
const timelineId = this.generateId('timeline');
|
|
let currentTime = 0;
|
|
const animations = [];
|
|
|
|
sequence.forEach(step => {
|
|
const delay = step.at !== undefined ? step.at : currentTime;
|
|
const animation = this.animate(step.element, step.keyframes, {
|
|
...step.options,
|
|
delay
|
|
});
|
|
|
|
animations.push(animation);
|
|
|
|
if (step.at === undefined) {
|
|
currentTime += step.options?.duration || this.defaultDuration;
|
|
}
|
|
});
|
|
|
|
return {
|
|
id: timelineId,
|
|
animations,
|
|
finished: Promise.all(animations.map(a => a.finished)),
|
|
play: () => animations.forEach(a => a.play()),
|
|
pause: () => animations.forEach(a => a.pause()),
|
|
reverse: () => animations.forEach(a => a.reverse()),
|
|
cancel: () => animations.forEach(a => a.cancel())
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Scroll-triggered animations
|
|
*/
|
|
onScroll(element, keyframes, options = {}) {
|
|
const scrollTrigger = options.trigger || element;
|
|
const startOffset = options.start || 0;
|
|
const endOffset = options.end || 1;
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const progress = Math.min(Math.max(
|
|
(entry.intersectionRatio - startOffset) / (endOffset - startOffset),
|
|
0
|
|
), 1);
|
|
|
|
if (progress >= 0) {
|
|
this.animate(element, keyframes, {
|
|
...options,
|
|
duration: options.duration || this.defaultDuration
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}, {
|
|
threshold: this.createThresholdArray(20)
|
|
});
|
|
|
|
observer.observe(scrollTrigger);
|
|
|
|
return {
|
|
observer,
|
|
disconnect: () => observer.disconnect()
|
|
};
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
enhanceAnimation(animation, id, metadata) {
|
|
return {
|
|
id,
|
|
animation,
|
|
metadata,
|
|
|
|
// Enhanced controls
|
|
play: () => animation.play(),
|
|
pause: () => animation.pause(),
|
|
reverse: () => animation.reverse(),
|
|
cancel: () => animation.cancel(),
|
|
finish: () => animation.finish(),
|
|
|
|
// Properties
|
|
get currentTime() { return animation.currentTime; },
|
|
set currentTime(time) { animation.currentTime = time; },
|
|
|
|
get playbackRate() { return animation.playbackRate; },
|
|
set playbackRate(rate) { animation.playbackRate = rate; },
|
|
|
|
get playState() { return animation.playState; },
|
|
get finished() { return animation.finished; },
|
|
|
|
// Enhanced methods
|
|
seek(progress) {
|
|
animation.currentTime = animation.effect.getTiming().duration * progress;
|
|
},
|
|
|
|
setProgress(progress) {
|
|
this.seek(Math.max(0, Math.min(1, progress)));
|
|
},
|
|
|
|
onFinish(callback) {
|
|
animation.addEventListener('finish', callback);
|
|
},
|
|
|
|
onCancel(callback) {
|
|
animation.addEventListener('cancel', callback);
|
|
}
|
|
};
|
|
}
|
|
|
|
fallbackAnimate(element, keyframes, options, id) {
|
|
Logger.info('[AnimationManager] Using CSS fallback animation');
|
|
|
|
// Simple CSS transition fallback
|
|
const finalFrame = keyframes[keyframes.length - 1];
|
|
element.style.transition = `all ${options.duration}ms ${options.easing}`;
|
|
|
|
// Apply final styles
|
|
Object.assign(element.style, finalFrame);
|
|
|
|
// Return promise-like object
|
|
return {
|
|
id,
|
|
finished: new Promise(resolve => setTimeout(resolve, options.duration)),
|
|
play: () => {},
|
|
pause: () => {},
|
|
cancel: () => {
|
|
element.style.transition = '';
|
|
}
|
|
};
|
|
}
|
|
|
|
generateId(prefix = 'anim') {
|
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
createThresholdArray(steps) {
|
|
const thresholds = [];
|
|
for (let i = 0; i <= steps; i++) {
|
|
thresholds.push(i / steps);
|
|
}
|
|
return thresholds;
|
|
}
|
|
|
|
/**
|
|
* Get all active animations
|
|
*/
|
|
getActiveAnimations() {
|
|
return Array.from(this.activeAnimations.entries()).map(([id, animation]) => ({
|
|
id,
|
|
playState: animation.playState,
|
|
currentTime: animation.currentTime,
|
|
metadata: animation.metadata
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Cancel all active animations
|
|
*/
|
|
cancelAll() {
|
|
this.activeAnimations.forEach(animation => {
|
|
animation.cancel();
|
|
});
|
|
this.activeAnimations.clear();
|
|
Logger.info('[AnimationManager] All animations cancelled');
|
|
}
|
|
|
|
/**
|
|
* Pause all active animations
|
|
*/
|
|
pauseAll() {
|
|
this.activeAnimations.forEach(animation => {
|
|
animation.pause();
|
|
});
|
|
Logger.info('[AnimationManager] All animations paused');
|
|
}
|
|
|
|
/**
|
|
* Resume all paused animations
|
|
*/
|
|
resumeAll() {
|
|
this.activeAnimations.forEach(animation => {
|
|
if (animation.playState === 'paused') {
|
|
animation.play();
|
|
}
|
|
});
|
|
Logger.info('[AnimationManager] All animations resumed');
|
|
}
|
|
} |