countdown-8.x-1.8/js/lib/effects/countdown.effects.js

js/lib/effects/countdown.effects.js
/**
 * CountdownTimer Effects Module - Consolidated digit animation effects
 * @version 1.0.0-alpha2
 * @license MIT
 *
 * Provides multiple digit-rendering animations: slide, fade, swap, bounce
 * Shared engine with per-effect strategies for optimal performance
 */
(function (root, factory) {
  'use strict';
  if (typeof define === 'function' && define.amd) {
    define([], factory);
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory();
  } else {
    factory();
  }
}(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  // Gracefully handle missing core
  const CountdownTimer = (typeof window !== 'undefined' && window.CountdownTimer) ||
    (typeof global !== 'undefined' && global.CountdownTimer);

  if (!CountdownTimer || typeof CountdownTimer.registerStyle !== 'function') {
    console.warn('[CountdownTimer.Effects] Core library not found or missing registerStyle');
    return;
  }

  /**
   * Effect strategies define animation behavior per effect type
   * @private
   */
  const EFFECT_STRATEGIES = {
    slide: {
      name: 'slide',
      duration: 300,
      easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
      getInitialState: (direction) => ({
        transform: `translateY(${-direction * 100}%)`,
        opacity: '0'
      }),
      getTargetState: (direction) => ({
        transform: `translateY(${direction * 100}%)`,
        opacity: '0'
      })
    },
    fade: {
      name: 'fade',
      duration: 250,
      easing: 'ease-in-out',
      getInitialState: () => ({
        transform: 'translateY(0)',
        opacity: '0'
      }),
      getTargetState: () => ({
        transform: 'translateY(0)',
        opacity: '0'
      })
    },
    swap: {
      name: 'swap',
      duration: 100,
      easing: 'linear',
      getInitialState: () => ({
        transform: 'scale(0.8)',
        opacity: '0'
      }),
      getTargetState: () => ({
        transform: 'scale(1.2)',
        opacity: '0'
      })
    },
    bounce: {
      name: 'bounce',
      duration: 400,
      easing: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
      getInitialState: (direction) => ({
        transform: `translateY(${-direction * 120}%)`,
        opacity: '0'
      }),
      getTargetState: (direction) => ({
        transform: `translateY(${direction * 120}%)`,
        opacity: '0'
      })
    }
  };

  /**
   * Unified effects renderer with shared digit engine
   * @class
   */
  class EffectsRenderer {
    /**
     * @param {CountdownTimer} timer - Timer instance
     * @param {Object} options - Effect options
     * @param {string} [options.effect='slide'] - Effect name
     * @param {number} [options.duration] - Override duration in ms
     * @param {string} [options.easing] - Override CSS easing
     */
    constructor(timer, options = {}) {
      this.timer = timer;
      this.element = timer._element;

      // Determine effect from options or config
      const effectName = options.effect || timer.config.style || 'slide';
      this.strategy = EFFECT_STRATEGIES[effectName] || EFFECT_STRATEGIES.slide;

      this.options = {
        duration: options.duration || this.strategy.duration,
        easing: options.easing || this.strategy.easing,
        ...options
      };

      this.prevFormatted = '';
      this.isCountdown = timer.config.mode === 'countdown';
      this.digitElements = [];
      this.transitions = new Map();
      this.timeouts = new Map();
      this.isDestroyed = false;

      this._setupDOM();
    }

    /**
     * Setup DOM container with appropriate classes and attributes
     * @private
     */
    _setupDOM() {
      if (!this.element || this.isDestroyed) return;

      // Clear and setup container
      this.element.textContent = '';
      this.element.classList.add('ct-effect');
      this.element.classList.add(`ct-effect--${this.strategy.name}`);

      // Set CSS variables for customization
      this.element.style.setProperty('--ct-duration', `${this.options.duration}ms`);
      this.element.style.setProperty('--ct-easing', this.options.easing);

      // Accessibility attributes
      this.element.setAttribute('role', 'timer');
      this.element.setAttribute('aria-live', 'polite');
      this.element.setAttribute('aria-atomic', 'true');
    }

    /**
     * Main render method called on each tick
     * @param {string} formatted - Formatted time string
     * @param {Object} components - Time components
     */
    render(formatted, components) {
      if (!this.element || this.isDestroyed) return;

      // Detect millisecond precision for snap behavior
      const decimalIndex = formatted.indexOf('.');
      const hasMilliseconds = decimalIndex > -1;

      // Ensure digit containers match string length
      this._ensureDigitContainers(formatted.length);

      // Update each character
      for (let i = 0; i < formatted.length; i++) {
        const char = formatted[i];
        const prevChar = i < this.prevFormatted.length ? this.prevFormatted[i] : undefined;
        const isMillisecondDigit = hasMilliseconds && i > decimalIndex;

        if (char !== prevChar) {
          if (isMillisecondDigit) {
            // Snap update for millisecond digits (no animation)
            this._snapDigit(i, char);
          } else {
            // Animated update for regular digits
            this._animateDigit(i, char, prevChar);
          }
        }
      }

      this.prevFormatted = formatted;

      // Update ARIA label for accessibility
      this.element.setAttribute('aria-label', this._getAriaLabel(components));
    }

    /**
     * Ensure we have the right number of digit containers
     * @private
     */
    _ensureDigitContainers(count) {
      // Add containers if needed
      while (this.digitElements.length < count) {
        const container = document.createElement('span');
        container.className = 'ct-effect__digit';
        container.dataset.digitIndex = this.digitElements.length;

        // Add initial layer
        const layer = document.createElement('span');
        layer.className = 'ct-effect__layer ct-effect__layer--current';
        layer.textContent = ' ';
        container.appendChild(layer);

        this.element.appendChild(container);
        this.digitElements.push(container);
      }

      // Remove extra containers
      while (this.digitElements.length > count) {
        const container = this.digitElements.pop();
        this._cleanupDigit(container);
        container.remove();
      }
    }

    /**
     * Instantly update a digit without animation (for milliseconds)
     * @private
     */
    _snapDigit(index, newChar) {
      const container = this.digitElements[index];
      if (!container || this.isDestroyed) return;

      // Clean up any pending animations
      this._cleanupDigit(container);

      // Get or create current layer
      let currentLayer = container.querySelector('.ct-effect__layer--current');
      if (!currentLayer) {
        currentLayer = document.createElement('span');
        currentLayer.className = 'ct-effect__layer ct-effect__layer--current';
        container.appendChild(currentLayer);
      }

      // Remove any other layers
      const allLayers = container.querySelectorAll('.ct-effect__layer');
      allLayers.forEach(layer => {
        if (layer !== currentLayer) {
          layer.remove();
        }
      });

      // Direct update with no animation
      currentLayer.textContent = newChar;
      currentLayer.style.transform = '';
      currentLayer.style.opacity = '';
      currentLayer.style.willChange = '';
      currentLayer.classList.remove('ct-effect__layer--animating');
    }

    /**
     * Animate a digit change using the current effect strategy
     * @private
     */
    _animateDigit(index, newChar, oldChar) {
      const container = this.digitElements[index];
      if (!container || this.isDestroyed) return;

      const isMotionOk = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      // Clean up existing transitions
      this._cleanupDigit(container);

      // Get or create current layer
      let currentLayer = container.querySelector('.ct-effect__layer--current');
      if (!currentLayer) {
        currentLayer = document.createElement('span');
        currentLayer.className = 'ct-effect__layer ct-effect__layer--current';
        currentLayer.textContent = oldChar || ' ';
        container.appendChild(currentLayer);
      }

      if (!isMotionOk || !oldChar) {
        // No animation for reduced motion or initial render
        currentLayer.textContent = newChar;
        return;
      }

      // Create incoming layer
      const incomingLayer = document.createElement('span');
      incomingLayer.className = 'ct-effect__layer ct-effect__layer--incoming';
      incomingLayer.textContent = newChar;

      // Get animation states from strategy
      const direction = this.isCountdown ? -1 : 1;
      const initialState = this.strategy.getInitialState(direction);
      const targetState = this.strategy.getTargetState(direction);

      // Set initial state for incoming layer
      Object.assign(incomingLayer.style, initialState);
      incomingLayer.style.willChange = 'transform, opacity';

      // Add to container
      container.appendChild(incomingLayer);

      // Force layout to ensure initial state is applied
      void container.offsetHeight;

      // Add animation class
      currentLayer.classList.add('ct-effect__layer--animating');
      incomingLayer.classList.add('ct-effect__layer--animating');

      // Apply will-change to current layer
      currentLayer.style.willChange = 'transform, opacity';

      // Start animations
      requestAnimationFrame(() => {
        if (this.isDestroyed) return;

        // Move current layer out
        Object.assign(currentLayer.style, targetState);

        // Move incoming layer in
        incomingLayer.style.transform = 'translateY(0) scale(1)';
        incomingLayer.style.opacity = '1';
      });

      // Track transition for cleanup
      const transitionData = {
        currentLayer,
        incomingLayer,
        container,
        completed: false,
        transitionEndHandler: null
      };

      this.transitions.set(index, transitionData);

      // Cleanup handler
      const cleanup = () => {
        if (transitionData.completed || this.isDestroyed) return;
        transitionData.completed = true;

        // Remove old layer
        if (currentLayer.parentNode === container) {
          currentLayer.remove();
        }

        // Reset incoming layer to become current
        incomingLayer.classList.remove('ct-effect__layer--animating', 'ct-effect__layer--incoming');
        incomingLayer.classList.add('ct-effect__layer--current');
        incomingLayer.style.transform = '';
        incomingLayer.style.opacity = '';
        incomingLayer.style.willChange = '';

        // Clear timeout
        const timeoutId = this.timeouts.get(index);
        if (timeoutId) {
          clearTimeout(timeoutId);
          this.timeouts.delete(index);
        }

        this.transitions.delete(index);
      };

      // Use transitionend with timeout fallback
      const transitionEndHandler = (e) => {
        if (e.target === incomingLayer && e.propertyName === 'transform') {
          cleanup();
        }
      };

      incomingLayer.addEventListener('transitionend', transitionEndHandler);
      transitionData.transitionEndHandler = { element: incomingLayer, handler: transitionEndHandler };

      // Safety timeout (50ms buffer)
      const timeoutId = setTimeout(cleanup, this.options.duration + 50);
      this.timeouts.set(index, timeoutId);
    }

    /**
     * Clean up any pending animations for a digit
     * @private
     */
    _cleanupDigit(container) {
      const index = parseInt(container.dataset.digitIndex, 10);

      // Clear timeout
      const timeoutId = this.timeouts.get(index);
      if (timeoutId) {
        clearTimeout(timeoutId);
        this.timeouts.delete(index);
      }

      // Clean up transition
      const transition = this.transitions.get(index);
      if (transition && !transition.completed) {
        transition.completed = true;

        // Remove event listeners
        if (transition.transitionEndHandler) {
          const { element, handler } = transition.transitionEndHandler;
          element.removeEventListener('transitionend', handler);
        }

        // Clean up layers
        if (transition.currentLayer) {
          transition.currentLayer.classList.remove('ct-effect__layer--animating');
          transition.currentLayer.style.willChange = '';
          if (transition.currentLayer.parentNode) {
            transition.currentLayer.remove();
          }
        }

        if (transition.incomingLayer) {
          transition.incomingLayer.classList.remove('ct-effect__layer--animating', 'ct-effect__layer--incoming');
          transition.incomingLayer.classList.add('ct-effect__layer--current');
          transition.incomingLayer.style.transform = '';
          transition.incomingLayer.style.opacity = '';
          transition.incomingLayer.style.willChange = '';
        }

        this.transitions.delete(index);
      }
    }

    /**
     * Generate ARIA label for accessibility
     * @private
     */
    _getAriaLabel(components) {
      const parts = [];
      if (components.days > 0) parts.push(`${components.days} day${components.days !== 1 ? 's' : ''}`);
      if (components.hours > 0) parts.push(`${components.hours} hour${components.hours !== 1 ? 's' : ''}`);
      if (components.minutes > 0) parts.push(`${components.minutes} minute${components.minutes !== 1 ? 's' : ''}`);
      if (components.seconds > 0) parts.push(`${components.seconds} second${components.seconds !== 1 ? 's' : ''}`);
      if (components.milliseconds > 0 && parts.length === 0) parts.push(`${components.milliseconds} milliseconds`);

      return parts.join(', ') || '0 seconds';
    }

    /**
     * Clean up and destroy the renderer
     */
    destroy() {
      this.isDestroyed = true;

      // Clean up all timeouts
      for (const timeoutId of this.timeouts.values()) {
        clearTimeout(timeoutId);
      }
      this.timeouts.clear();

      // Clean up all transitions
      for (const transition of this.transitions.values()) {
        if (!transition.completed) {
          transition.completed = true;

          // Remove listeners
          if (transition.transitionEndHandler) {
            const { element, handler } = transition.transitionEndHandler;
            element.removeEventListener('transitionend', handler);
          }

          if (transition.currentLayer && transition.currentLayer.parentNode) {
            transition.currentLayer.remove();
          }
          if (transition.incomingLayer) {
            transition.incomingLayer.style.willChange = '';
          }
        }
      }
      this.transitions.clear();

      // Clean up DOM
      if (this.element) {
        this.element.classList.remove('ct-effect', `ct-effect--${this.strategy.name}`);
        this.element.style.removeProperty('--ct-duration');
        this.element.style.removeProperty('--ct-easing');
        this.element.removeAttribute('role');
        this.element.removeAttribute('aria-live');
        this.element.removeAttribute('aria-atomic');
        this.element.removeAttribute('aria-label');
        this.element.textContent = '';
      }

      this.digitElements = [];
      this.timer = null;
      this.element = null;
    }
  }

  // Register all effects
  Object.keys(EFFECT_STRATEGIES).forEach(effectName => {
    CountdownTimer.registerStyle(effectName, EffectsRenderer);
  });

  // Also support the newer 'effect' naming if registerEffect exists
  if (typeof CountdownTimer.registerEffect === 'function') {
    Object.keys(EFFECT_STRATEGIES).forEach(effectName => {
      CountdownTimer.registerEffect(effectName, EffectsRenderer);
    });
  }

  return EffectsRenderer;
}));

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc