splide icon indicating copy to clipboard operation
splide copied to clipboard

Splides keep rewinding even with 'Rewind : False' and 'Type : Loop'

Open OZORDI opened this issue 1 year ago • 1 comments

Checks

  • [X] Not a duplicate.
  • [X] Not a question, feature request, or anything other than a bug report directly related to Splide. Use Discussions for these topics: https://github.com/Splidejs/splide/discussions

Version

v4.1.4

Description

Heres a video showing my current splide with the rewind issue:

Heres a video showing the older version of my splide where i fixed the rewind issue but the Splide would stutter reaching the end:

I have a looping slider that has this weird wrap around after it reaches the last splide. I can confirm its not even a 'Rewind : True' somehow being set on it because trying to configure the wrap around time using 'rewindSpeed : 2000' does not affect the rewind time. In addition i explicitly have "type: "loop" meaning rewind option should be ignored.

The first time i had it, I fixed it by setting 'autoHeight: true' and 'autoWidth: true' to my base config because I suspected Splide was having an issue figuring out the width of each slide. While it did fix the wrap around issue it caused the same stutter described in this issue. I then somehow regressed and the splides started wrapping around again.

Heres my current Splide config

<script type="module">
import Splide from 'https://cdn.jsdelivr.net/npm/@splidejs/[email protected]/+esm';

const splide = new Splide(".splide", {
    type: "loop",
    updateOnMove: true,
    perPage: 3,
    focus: "center",
    perMove: 1,
    start: 0,
    drag: false,
    easing: "cubic-bezier(.645, .045, .355, 1)",
    gap: "3rem",
    pagination: false,
    padding: "2rem",
    direction: "ltr",
    fixedWidth: '420px',
    speed: 1000,
    mediaQuery: 'max',
    breakpoints: {
        991: {
            perPage: 3,
            fixedWidth: '420px',
            autoHeight: true,
        },
        478: {
            gap: "3rem",
            padding: "4rem",
            perPage: 3,
            fixedWidth: '70vw',
            autoHeight: true,
        },
    },
});

// Mount the Splide instance
splide.mount();
</script>

Heres my older splide config

<script type="module">
import Splide from 'https://cdn.jsdelivr.net/npm/@splidejs/[email protected]/+esm';
document.addEventListener('DOMContentLoaded', () => {
    const splide = new Splide(".splide", {
        type: "loop",
        updateOnMove: true,
        perPage: 3,
        focus: "center",
        perMove: 1,
        start: 0,
        drag: false,
        easing: "cubic-bezier(.645, .045, .355, 1)",
        gap: "3rem",
        pagination: false,
        padding: "2rem",
        direction: "ltr",
        autoWidth: true,
        autoHeight: true,
        speed: 1000,
        mediaQuery: 'max',
        breakpoints: {
            991: {
                perPage: 3,
                autoWidth: true,
                autoHeight: true,
            },
            478: {
                gap: "3rem",
                padding: "4rem",
                perPage: 3,
                width: "70vw",
            },
        },
    });

    // Mount the Splide instance
    splide.mount();
});
</script>

Reproduction Link

Heres a js fiddle with a more complete setup of my old version. Its really a standard splide setup (besides the animated bg gradients)

Steps to Reproduce

Use my older splide config on a standard splide setup and you should get the weird stutter issue im describing On my new splide config it just rewinds when reaching the last splide.

Expected Behaviour

The splides are supposed to infinitely loop smoothly with no stutter or wrapping. I can confirm Splide is still auto generating clones at runtime meaning the 'type:loop' option is working partially.

OZORDI avatar Nov 19 '24 19:11 OZORDI

Same here.

Just a simple config.

new Splide('.splide', { type: 'loop' });

It just rewinds instead of going foward with new clones.

waaverecords avatar Nov 19 '24 19:11 waaverecords

did you happen to have this css payload on your page as well?

OZORDI avatar Oct 17 '25 23:10 OZORDI

For closure i ended up just creating my own splide js alternative. All the bugs are gone and it keeps splides core funcionalities like swiping and dragging.

Video Link: https://streamable.com/finbhn

Heres a sample implementation of the library:

  1. CSS File (infinite-carousel.css)
/* Core carousel transforms for performance */
.splide__slide,
.splide__list,
.splide__track {
  -webkit-transform: translateZ(0);
  -webkit-backface-visibility: hidden;
  transform: translateZ(0);
}

.splide-image {
  transform: translateZ(0);
}

/* Pagination styling */
.splide__pagination__page.centered {
  background: #feffff;
  border: 0;
  border-radius: 50%;
  display: inline-block;
  height: 8px;
  margin: 3px;
  opacity: 1;
  padding: 0;
  position: relative;
  transition: transform 1s cubic-bezier(0.645, 0.045, 0.355, 1);
  width: 8px;
}

.splide__pagination__page.centered.is-active,
.splide__pagination__page.centered.is-activating {
  background: #e5693b;
  transform: scale(1.4);
}

/* Slide animations */
.splide__slide.centered.is-active,
.splide__slide.centered.is-activating {
  filter: brightness(100%) saturate(100%);
  transform: scale(1.1);
}

/* Gradient background for carousel container */
.why-ais-scrolling-cards {
  background-image: linear-gradient(
    var(--gradient-start),
    var(--gradient-end)
  );
}
  1. Main Library (infinite-carousel.js)
/**
 * InfiniteCarousel Library
 * A feature-rich, infinite-scrolling carousel with color extraction,
 * gradient animations, and autoplay capabilities.
 *
 * @version 1.0.0
 * @requires GSAP (for gradient animations)
 */

(function (global) {
  "use strict";

  // ==================== DEFAULT CONFIGURATION ====================
  const DEFAULT_CONFIG = {
    // Core carousel settings
    carousel: {
      selector: ".splide.centered",
      gap: 48,
      padding: 32,
      slideWidth: 420,
      speed: 1000,
      easing: "cubic-bezier(.645, .045, .355, 1)",
      focusCenter: true,
      mobileOffset: -17.5,
      cloneCount: null, // Auto-calculated if null
    },

    // Autoplay settings
    autoplay: {
      enabled: true,
      interval: 2000,
      pauseOnHover: false,
    },

    // Color extraction settings
    colors: {
      enabled: true,
      sampleSize: 100,
      cacheColors: true,
    },

    // Dynamic text color settings
    dynamicText: {
      enabled: true,
      updateInterval: 3000,
      contrastThreshold: 7,
      safariWhiteText: true, // Force white text in Safari
    },

    // Gradient animation settings
    gradients: {
      enabled: true,
      duration: 0.9,
      colorPairs: {
        start: "vibrant", // or 'dominant', 'muted', etc.
        end: "dvibrant", // dark vibrant
      },
    },

    // Card background settings
    cardBackgrounds: {
      enabled: true,
      batchSize: 4,
      darkenWeight: 0.67, // 0-1, blend between black and vibrant
    },

    // Responsive settings
    responsive: {
      mobile: {
        breakpoint: 478,
        slideWidth: "70vw", // Can be number or percentage string
        gap: 48,
        padding: 64,
      },
    },

    // Debug mode
    debug: false,
  };

  // ==================== UTILITY FUNCTIONS ====================
  const Utils = {
    // Color conversion utilities
    hexToRgb(hex) {
      const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
      return result
        ? {
            r: parseInt(result[1], 16),
            g: parseInt(result[2], 16),
            b: parseInt(result[3], 16),
          }
        : null;
    },

    rgbToHex(r, g, b) {
      const toHex = (x) => {
        const hex = x.toString(16);
        return hex.length === 1 ? "0" + hex : hex;
      };
      return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
    },

    rgbToHsl(r, g, b) {
      r /= 255;
      g /= 255;
      b /= 255;
      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      let h,
        s,
        l = (max + min) / 2;

      if (max === min) {
        h = s = 0;
      } else {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
        switch (max) {
          case r:
            h = (g - b) / d + (g < b ? 6 : 0);
            break;
          case g:
            h = (b - r) / d + 2;
            break;
          case b:
            h = (r - g) / d + 4;
            break;
        }
        h /= 6;
      }
      return [h * 360, s * 100, l * 100];
    },

    parseRgb(rgbString) {
      const match = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
      return match
        ? {
            r: parseInt(match[1]),
            g: parseInt(match[2]),
            b: parseInt(match[3]),
          }
        : null;
    },

    getLuminance(r, g, b) {
      return [r, g, b]
        .map((c) => {
          c = c / 255;
          return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
        })
        .reduce(
          (acc, val, idx) => acc + val * [0.2126, 0.7152, 0.0722][idx],
          0
        );
    },

    getContrastRatio(l1, l2) {
      const lighter = Math.max(l1, l2);
      const darker = Math.min(l1, l2);
      return (lighter + 0.05) / (darker + 0.05);
    },

    interpolateColor(color1, color2, weight) {
      const c1 = color1.match(/\w\w/g).map((hex) => parseInt(hex, 16));
      const c2 = color2.match(/\w\w/g).map((hex) => parseInt(hex, 16));
      const interpolated = c1.map((comp, index) =>
        Math.round(comp * (1 - weight) + c2[index] * weight)
      );
      return `#${interpolated.map((comp) => comp.toString(16).padStart(2, "0")).join("")}`;
    },

    isSafari() {
      return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
    },

    log(message, ...args) {
      if (this.config && this.config.debug) {
        console.log(`[InfiniteCarousel] ${message}`, ...args);
      }
    },

    warn(message, ...args) {
      console.warn(`[InfiniteCarousel] ${message}`, ...args);
    },

    error(message, ...args) {
      console.error(`[InfiniteCarousel] ${message}`, ...args);
    },
  };

  // ==================== COLOR EXTRACTOR ====================
  class ColorExtractor {
    constructor(config) {
      this.config = config;
      this.cache = new Map();
    }

    async extractFromUrl(imageUrl) {
      if (this.config.cacheColors && this.cache.has(imageUrl)) {
        return this.cache.get(imageUrl);
      }

      try {
        const imageData = await this._getImageData(imageUrl);
        const analyzedColors = this._analyzeColors(imageData);
        const colors = this._findBestColors(analyzedColors);

        if (this.config.cacheColors) {
          this.cache.set(imageUrl, colors);
        }

        return colors;
      } catch (error) {
        Utils.error("Color extraction failed:", error);
        return this._getFallbackColors();
      }
    }

    async _getImageData(url) {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onerror = () => reject(new Error("Failed to load image"));

        img.onload = () => {
          const canvas = document.createElement("canvas");
          const ctx = canvas.getContext("2d", { willReadFrequently: true });

          const scale = Math.min(1, this.config.sampleSize / img.width);
          canvas.width = img.width * scale;
          canvas.height = img.height * scale;

          ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
          const imageData = ctx.getImageData(
            0,
            0,
            canvas.width,
            canvas.height
          );
          resolve(imageData.data);
        };

        img.src = url;
      });
    }

    _analyzeColors(pixels) {
      const colorMap = new Map();
      const colorProperties = new Map();

      for (let i = 0; i < pixels.length; i += 4) {
        const r = pixels[i];
        const g = pixels[i + 1];
        const b = pixels[i + 2];
        const hex = Utils.rgbToHex(r, g, b);

        colorMap.set(hex, (colorMap.get(hex) || 0) + 1);

        if (!colorProperties.has(hex)) {
          const [h, s, l] = Utils.rgbToHsl(r, g, b);
          colorProperties.set(hex, {
            hex,
            hsl: [h, s, l],
            rgb: [r, g, b],
            saturation: s,
            luminance: l,
            popularity: 0,
          });
        }
      }

      const totalPixels = pixels.length / 4;
      for (const [hex, count] of colorMap.entries()) {
        const props = colorProperties.get(hex);
        props.popularity = count / totalPixels;
      }

      return Array.from(colorProperties.values());
    }

    _findBestColors(colors) {
      colors.sort((a, b) => b.popularity - a.popularity);

      const result = {
        dominant: colors[0].hex,
        vibrant: null,
        muted: null,
        lvibrant: null,
        lmuted: null,
        dvibrant: null,
        dmuted: null,
        ldominant: null,
        ddominant: null,
      };

      const findColor = (
        targetSaturation,
        targetLuminance,
        saturationRange = 20,
        luminanceRange = 20
      ) => {
        return (
          colors.find((color) => {
            const [, s, l] = color.hsl;
            return (
              Math.abs(s - targetSaturation) <= saturationRange &&
              Math.abs(l - targetLuminance) <= luminanceRange
            );
          })?.hex || result.dominant
        );
      };

      result.vibrant = findColor(90, 50);
      result.lvibrant = findColor(90, 75);
      result.dvibrant = findColor(90, 25);
      result.muted = findColor(40, 50);
      result.lmuted = findColor(40, 75);
      result.dmuted = findColor(40, 25);
      result.ldominant =
        colors.find((color) => color.luminance >= 70)?.hex || result.dominant;
      result.ddominant =
        colors.find((color) => color.luminance <= 30)?.hex || result.dominant;

      for (const key in result) {
        if (!result[key]) result[key] = result.dominant;
      }

      return result;
    }

    _getFallbackColors() {
      const fallback = "#808080";
      return {
        dominant: fallback,
        vibrant: fallback,
        muted: fallback,
        lvibrant: fallback,
        lmuted: fallback,
        dvibrant: fallback,
        dmuted: fallback,
        ldominant: fallback,
        ddominant: fallback,
      };
    }

    clearCache() {
      this.cache.clear();
    }
  }

  // ==================== CORE CAROUSEL ====================
  class InfiniteCarousel {
    constructor(selector, config) {
      this._isFullyInitialized = false;
      this._isInitializing = false;

      this.container = document.querySelector(selector);
      if (!this.container) {
        throw new Error(
          `InfiniteCarousel: Container not found for selector "${selector}"`
        );
      }

      this.track = this.container.querySelector(".splide__track");
      this.list = this.container.querySelector(".splide__list");

      if (!this.track || !this.list) {
        throw new Error("InfiniteCarousel: Required elements not found");
      }

      this.slides = Array.from(
        this.list.querySelectorAll(
          ".splide__slide:not(.splide__slide--clone)"
        )
      );

      if (this.slides.length === 0) {
        throw new Error("InfiniteCarousel: No slides found");
      }

      // Store configuration
      this.config = config;
      this.gap = config.gap;
      this.padding = config.padding;
      this.slideWidth = config.slideWidth;
      this.speed = config.speed;
      this.easing = config.easing;
      this.focusCenter = config.focusCenter;
      this.mobileOffset = config.mobileOffset;

      // Navigation elements
      this.prevBtn = this.container.querySelector(".splide__arrow--prev");
      this.nextBtn = this.container.querySelector(".splide__arrow--next");
      this.paginationEls = Array.from(
        this.container.querySelectorAll(".splide__pagination__page")
      );

      // State
      this.originalSlideCount = this.slides.length;
      this.CLONE_COUNT =
        config.cloneCount ||
        Math.max(6, Math.ceil(this.originalSlideCount / 2));
      this.currentIndex = this.CLONE_COUNT;
      this.targetIndex = null;
      this.isAnimating = false;
      this.isRepositioning = false;
      this.realIndex = 0;
      this.index = 0;
      this.isInViewport = false;

      // Interaction state
      this._navigationLocked = false;
      this._pointerActive = false;
      this._isDragging = false;
      this._touchActive = false;
      this._hasTouch = false;
      this._startX = 0;
      this._lastX = 0;
      this._startT = 0;
      this._lastMoveT = 0;
      this._velocityX = 0;
      this._dragThreshold = 10;
      this._swipeTrigger = 50;

      // Event system
      this.eventHandlers = {
        mounted: [],
        move: [],
        moved: [],
        active: [],
        interactionStart: [],
        interactionEnd: [],
        visibilityChange: [],
      };

      // Components API
      this.Components = {
        Elements: { slides: [] },
        Slides: {
          getLength: () => this.originalSlideCount,
          getAt: (i) => this.Components.Elements.slides[i],
        },
      };

      // Observers
      this._viewportObserver = null;

      // Bind methods
      this._onTransitionEnd = this._onTransitionEnd.bind(this);
      this._onVisibilityChange = this._onVisibilityChange.bind(this);

      this.init();
    }

    // [Rest of the InfiniteCarousel methods remain the same as in original code]
    // I'll include the essential methods here for brevity

    mod(a, n) {
      return ((a % n) + n) % n;
    }

    _transitionCSS() {
      return `transform ${this.speed}ms ${this.easing}, filter ${this.speed}ms ${this.easing}`;
    }

    _applySlideTransition(el) {
      el.style.transition = this._transitionCSS();
    }

    _withSlidesNoTransition(fn) {
      const slides = this.Components.Elements.slides;
      const prev = new Array(slides.length);

      for (let i = 0; i < slides.length; i++) {
        prev[i] = slides[i].style.transition;
        slides[i].style.transition = "none";
      }

      try {
        fn();
      } finally {
        this.list.offsetHeight;
        for (let i = 0; i < slides.length; i++) {
          slides[i].style.transition = prev[i] || this._transitionCSS();
        }
      }
    }

    mapToReal(domIndex) {
      const n = this.originalSlideCount;
      const realStart = this.CLONE_COUNT;

      if (domIndex < realStart) {
        return (n - this.CLONE_COUNT + domIndex) % n;
      }
      if (domIndex >= realStart + n) {
        return this.mod(domIndex - (realStart + n), n);
      }
      return domIndex - realStart;
    }

    isBusy() {
      return (
        this._isDragging ||
        this._pointerActive ||
        this.isAnimating ||
        this.isRepositioning ||
        !this._isFullyInitialized
      );
    }

    init() {
      if (this._isInitializing) return;
      this._isInitializing = true;

      Utils.log("Initializing carousel");

      // Setup track and slides
      this.track.style.paddingLeft = this.padding + "px";
      this.track.style.paddingRight = this.padding + "px";

      this.slides.forEach((slide, i) => {
        slide.style.width = this.slideWidth + "px";
        slide.style.marginRight = this.gap + "px";
        slide.style.flexShrink = "0";
        this._applySlideTransition(slide);
        slide.classList.add("centered");
        slide.setAttribute("data-real-index", String(i));
      });

      this.list.offsetHeight;
      this.createClones();

      this.Components.Elements.slides = Array.from(
        this.list.querySelectorAll(".splide__slide")
      );

      this.Components.Elements.slides.forEach((el) => {
        el.classList.add("centered");
        this._applySlideTransition(el);
      });

      this.currentIndex = this.CLONE_COUNT;
      this.realIndex = 0;
      this.index = 0;

      this.updatePosition(false);
      this.updateActiveSlide();
      this.updatePagination();

      this.container.classList.add(
        "is-initialized",
        "is-active",
        "splide--loop",
        "splide--ltr",
        "splide--draggable"
      );

      this.list.setAttribute("role", "presentation");
      this.track.classList.add(
        "splide__track--loop",
        "splide__track--ltr",
        "splide__track--draggable"
      );

      requestAnimationFrame(() => {
        this.setupPagination();
        this.setupEventListeners();
        this.setupSwipeListeners();
        this.setupVisibilityHandler();
        this.setupViewportObserver();

        this._isFullyInitialized = true;
        this._isInitializing = false;

        Utils.log("Carousel mounted");
        this.emit("mounted");
      });
    }

    createClones() {
      const n = this.originalSlideCount;

      // Clone at end
      for (let i = 0; i < this.CLONE_COUNT; i++) {
        const srcIdx = i % n;
        const src = this.slides[srcIdx];
        const clone = src.cloneNode(true);

        clone.classList.add("splide__slide--clone", "centered");
        clone.id = `${src.id || `slide-${srcIdx}`}-clone-end-${i}`;
        clone.style.width = this.slideWidth + "px";
        clone.style.marginRight = this.gap + "px";
        clone.style.flexShrink = "0";
        this._applySlideTransition(clone);
        clone.setAttribute("data-real-index", String(srcIdx));

        this.list.appendChild(clone);
      }

      // Clone at start
      for (let i = this.CLONE_COUNT - 1; i >= 0; i--) {
        const srcIdx = (n - this.CLONE_COUNT + i) % n;
        const src = this.slides[srcIdx];
        const clone = src.cloneNode(true);

        clone.classList.add("splide__slide--clone", "centered");
        clone.id = `${src.id || `slide-${srcIdx}`}-clone-start-${i}`;
        clone.style.width = this.slideWidth + "px";
        clone.style.marginRight = this.gap + "px";
        clone.style.flexShrink = "0";
        this._applySlideTransition(clone);
        clone.setAttribute("data-real-index", String(srcIdx));

        this.list.insertBefore(clone, this.list.firstChild);
      }
    }

    setupPagination() {
      this.paginationEls.forEach((dot, i) => {
        dot.classList.add("centered");
        dot.addEventListener("click", () => {
          if (!this._isFullyInitialized || !this.isInViewport) return;
          this.emit("interactionStart", { type: "pagination" });
          this.go(i);
        });
      });
    }

    setupEventListeners() {
      if (this.nextBtn) {
        this.nextBtn.addEventListener("click", () => {
          if (!this._isFullyInitialized || !this.isInViewport) return;
          this.emit("interactionStart", { type: "button" });
          this.next();
        });
      }

      if (this.prevBtn) {
        this.prevBtn.addEventListener("click", () => {
          if (!this._isFullyInitialized || !this.isInViewport) return;
          this.emit("interactionStart", { type: "button" });
          this.prev();
        });
      }

      document.addEventListener("keydown", (e) => {
        if (!this._isFullyInitialized || !this.isInViewport) return;

        if (e.key === "ArrowRight" && !e.repeat) {
          this.emit("interactionStart", { type: "keyboard" });
          this.next();
        } else if (e.key === "ArrowLeft" && !e.repeat) {
          this.emit("interactionStart", { type: "keyboard" });
          this.prev();
        } else if (
          (e.key === " " || e.key === "Spacebar") &&
          !e.repeat
        ) {
          e.preventDefault();
          this.emit("interactionStart", { type: "keyboard-spacebar" });
        }
      });

      this.list.addEventListener("transitionend", this._onTransitionEnd);
    }

    setupViewportObserver() {
      this._viewportObserver = new IntersectionObserver(
        (entries) => {
          if (!this._isFullyInitialized) return;
          entries.forEach((entry) => {
            this.isInViewport = entry.isIntersecting;
            this.emit("visibilityChange", {
              isVisible: this.isInViewport,
            });
          });
        },
        { threshold: 0.01 }
      );

      this._viewportObserver.observe(this.container);
    }

    setupVisibilityHandler() {
      document.addEventListener("visibilitychange", this._onVisibilityChange);
    }

    _onVisibilityChange() {
      if (!this._isFullyInitialized) return;
      if (document.hidden && this._pointerActive) {
        this._cancelDrag();
      }
    }

    _cancelDrag() {
      this._pointerActive = false;
      this._isDragging = false;
      this._touchActive = false;
      this.list.classList.remove("is-dragging");

      if (this.targetIndex === null) {
        this.list.style.transition = this._transitionCSS();
        this.updatePosition(true);
      }

      this.emit("interactionEnd", { type: "drag-cancelled" });
    }

    setupSwipeListeners() {
      // Touch events
      this.list.addEventListener(
        "touchstart",
        (e) => {
          if (
            !this._isFullyInitialized ||
            e.touches.length !== 1 ||
            !this.isInViewport
          )
            return;

          this._hasTouch = true;
          this._touchActive = true;
          this.emit("interactionStart", { type: "touch" });

          const touch = e.touches[0];
          this._pointerActive = true;
          this._isDragging = false;
          this._startX = touch.clientX;
          this._lastX = touch.clientX;
          this._startT = performance.now();
          this._lastMoveT = this._startT;
          this._velocityX = 0;
        },
        { passive: true }
      );

      this.list.addEventListener(
        "touchmove",
        (e) => {
          if (
            !this._isFullyInitialized ||
            !this._pointerActive ||
            e.touches.length !== 1 ||
            !this.isInViewport
          )
            return;

          const touch = e.touches[0];
          const x = touch.clientX;
          const dx = x - this._startX;
          const now = performance.now();
          const dt = Math.max(1, now - this._lastMoveT);

          this._velocityX = (x - this._lastX) / dt;
          this._lastX = x;
          this._lastMoveT = now;

          if (!this._isDragging && Math.abs(dx) > this._dragThreshold) {
            this._isDragging = true;
            this.list.classList.add("is-dragging");
          }

          if (this._isDragging && !this.isRepositioning) {
            const offset = this.focusCenter
              ? this.track.offsetWidth / 2 -
                this.slideWidth / 2 -
                this.padding
              : 0;
            const mobileAdjustment =
              window.innerWidth <= 479 ? this.mobileOffset : 0;
            const baseDom =
              this.targetIndex !== null
                ? this.targetIndex
                : this.currentIndex;
            const baseTranslate =
              -(baseDom * (this.slideWidth + this.gap)) +
              offset +
              mobileAdjustment;

            this.list.style.transition = "none";
            this.list.style.transform = `translateX(${baseTranslate + dx}px)`;
          }
        },
        { passive: true }
      );

      this.list.addEventListener("touchend", () => {
        if (
          !this._isFullyInitialized ||
          !this._pointerActive ||
          !this.isInViewport
        )
          return;

        const totalDx = this._lastX - this._startX;
        this._pointerActive = false;
        this._touchActive = false;
        this.list.classList.remove("is-dragging");

        if (this._isDragging) {
          const isSwipe =
            Math.abs(this._velocityX) > 0.3 ||
            Math.abs(totalDx) > this._swipeTrigger;

          if (isSwipe) {
            if (totalDx < 0) this.next();
            else this.prev();
          } else {
            this.list.style.transition = this._transitionCSS();
            this.updatePosition(true);
            this.emit("interactionEnd", { type: "touch" });
          }
        } else {
          this.list.style.transition = this._transitionCSS();
          this.emit("interactionEnd", { type: "touch" });
        }

        this._isDragging = false;
      });

      this.list.addEventListener("touchcancel", () => {
        if (!this._isFullyInitialized || !this.isInViewport) return;
        this._cancelDrag();
      });

      // Mouse events
      this.list.addEventListener("mousedown", (e) => {
        if (
          !this._isFullyInitialized ||
          e.button !== 0 ||
          !this.isInViewport ||
          this._hasTouch
        )
          return;

        this.emit("interactionStart", { type: "mouse" });
        this._pointerActive = true;
        this._isDragging = false;
        this._startX = e.clientX;
        this._lastX = e.clientX;
        this._startT = performance.now();
        this._lastMoveT = this._startT;
        this._velocityX = 0;
        e.preventDefault();
      });

      window.addEventListener("mousemove", (e) => {
        if (
          !this._isFullyInitialized ||
          !this._pointerActive ||
          !this.isInViewport ||
          this._touchActive
        )
          return;

        const x = e.clientX;
        const dx = x - this._startX;
        const now = performance.now();
        const dt = Math.max(1, now - this._lastMoveT);

        this._velocityX = (x - this._lastX) / dt;
        this._lastX = x;
        this._lastMoveT = now;

        if (!this._isDragging && Math.abs(dx) > this._dragThreshold) {
          this._isDragging = true;
          this.list.classList.add("is-dragging");
        }

        if (this._isDragging && !this.isRepositioning) {
          const offset = this.focusCenter
            ? this.track.offsetWidth / 2 - this.slideWidth / 2 - this.padding
            : 0;
          const mobileAdjustment =
            window.innerWidth <= 479 ? this.mobileOffset : 0;
          const baseDom =
            this.targetIndex !== null ? this.targetIndex : this.currentIndex;
          const baseTranslate =
            -(baseDom * (this.slideWidth + this.gap)) +
            offset +
            mobileAdjustment;

          this.list.style.transition = "none";
          this.list.style.transform = `translateX(${baseTranslate + dx}px)`;
        }
      });

      window.addEventListener("mouseup", () => {
        if (
          !this._isFullyInitialized ||
          !this._pointerActive ||
          !this.isInViewport ||
          this._touchActive
        )
          return;

        const totalDx = this._lastX - this._startX;
        this._pointerActive = false;
        this.list.classList.remove("is-dragging");

        if (this._isDragging) {
          const isSwipe =
            Math.abs(this._velocityX) > 0.3 ||
            Math.abs(totalDx) > this._swipeTrigger;

          if (isSwipe) {
            if (totalDx < 0) this.next();
            else this.prev();
          } else {
            this.list.style.transition = this._transitionCSS();
            this.updatePosition(true);
            this.emit("interactionEnd", { type: "mouse" });
          }
        } else {
          this.list.style.transition = this._transitionCSS();
          this.emit("interactionEnd", { type: "mouse" });
        }

        this._isDragging = false;
      });

      this.list.addEventListener("dragstart", (e) => e.preventDefault());
    }

    next() {
      if (
        !this._isFullyInitialized ||
        this._navigationLocked ||
        this.isRepositioning
      )
        return;

      const current =
        this.targetIndex !== null ? this.targetIndex : this.currentIndex;
      this._navigateTo(current + 1);
    }

    prev() {
      if (
        !this._isFullyInitialized ||
        this._navigationLocked ||
        this.isRepositioning
      )
        return;

      const current =
        this.targetIndex !== null ? this.targetIndex : this.currentIndex;
      this._navigateTo(current - 1);
    }

    go(realIndex) {
      if (
        !this._isFullyInitialized ||
        this._navigationLocked ||
        this.isRepositioning
      )
        return;

      const clamped = this.mod(realIndex, this.originalSlideCount);
      const currentReal = this.mapToReal(
        this.targetIndex !== null ? this.targetIndex : this.currentIndex
      );

      let delta = clamped - currentReal;
      if (Math.abs(delta) > this.originalSlideCount / 2) {
        delta =
          delta > 0
            ? delta - this.originalSlideCount
            : delta + this.originalSlideCount;
      }

      const current =
        this.targetIndex !== null ? this.targetIndex : this.currentIndex;
      this._navigateTo(current + delta);
    }

    _navigateTo(toDom) {
      const total = this.Components.Elements.slides.length;
      const n = this.originalSlideCount;
      const current =
        this.targetIndex !== null ? this.targetIndex : this.currentIndex;

      let target = toDom;
      const delta = target - current;
      const clampedDelta = Math.max(-n, Math.min(n, delta));
      target = current + clampedDelta;

      if (target === current) return;

      while (target >= total) target -= n;
      while (target < 0) target += n;

      const fromDom = this.currentIndex;

      if (this.isAnimating) {
        this.targetIndex = target;
        this._updateActivatingClasses(target);
        this.updatePosition(true);
      } else {
        this.targetIndex = target;
        this.isAnimating = true;
        this._updateActivatingClasses(target);

        this.emit("move", {
          from: {
            domIndex: fromDom,
            realIndex: this.mapToReal(fromDom),
          },
          to: {
            domIndex: target,
            realIndex: this.mapToReal(target),
          },
        });

        this.updatePosition(true);
      }
    }

    _updateActivatingClasses(toDom) {
      const slides = this.Components.Elements.slides;

      slides.forEach((el) =>
        el.classList.remove(
          "is-activating",
          "is-deactivating",
          "is-active",
          "is-prev",
          "is-next"
        )
      );

      slides[toDom]?.classList.add("is-active", "is-activating");

      const toReal = this.mapToReal(toDom);
      this.paginationEls.forEach((dot, i) => {
        dot.classList.remove("is-activating", "is-deactivating");
        dot.classList.toggle("is-active", i === toReal);
        if (i === toReal) dot.classList.add("is-activating");
      });

      this.emit("active", {
        domIndex: toDom,
        realIndex: toReal,
        slideEl: slides[toDom],
        early: true,
      });
    }

    updatePosition(animate = true) {
      const offset = this.focusCenter
        ? this.track.offsetWidth / 2 - this.slideWidth / 2 - this.padding
        : 0;
      const mobileAdjustment =
        window.innerWidth <= 479 ? this.mobileOffset : 0;
      const domIndex =
        this.targetIndex !== null ? this.targetIndex : this.currentIndex;
      const translateX =
        -(domIndex * (this.slideWidth + this.gap)) + offset + mobileAdjustment;

      if (animate) {
        this.list.style.transition = `transform ${this.speed}ms ${this.easing}`;
      } else {
        this.list.style.transition = "none";
      }

      this.list.style.transform = `translateX(${translateX}px)`;
    }

    updateActiveSlide() {
      const slides = this.Components.Elements.slides;
      const total = slides.length;
      const centerDom = this.currentIndex;
      const prevIdx = this.mod(centerDom - 1, total);
      const nextIdx = this.mod(centerDom + 1, total);

      for (let i = 0; i < total; i++) {
        slides[i].classList.remove(
          "is-active",
          "is-prev",
          "is-next",
          "is-activating",
          "is-deactivating"
        );
      }

      slides[centerDom]?.classList.add("is-active");
      slides[prevIdx]?.classList.add("is-prev");
      slides[nextIdx]?.classList.add("is-next");
    }

    updatePagination() {
      if (!this.paginationEls.length) return;

      const activeReal = this.realIndex;
      this.paginationEls.forEach((dot, i) => {
        dot.classList.remove("is-activating", "is-deactivating");
        dot.classList.toggle("is-active", i === activeReal);
      });
    }

    _onTransitionEnd(e) {
      if (
        !this._isFullyInitialized ||
        e.target !== this.list ||
        e.propertyName !== "transform" ||
        this.targetIndex === null ||
        !this.isAnimating
      )
        return;

      this.currentIndex = this.targetIndex;
      this.targetIndex = null;
      this.isAnimating = false;
      this.realIndex = this.mapToReal(this.currentIndex);
      this.index = this.realIndex;

      const didReposition = this.checkRepositionWithClassMirror();

      const finalize = () => {
        const slides = this.Components.Elements.slides;
        slides.forEach((el) =>
          el.classList.remove("is-activating", "is-deactivating")
        );
        this.updateActiveSlide();
        this.updatePagination();
      };

      if (didReposition) {
        this._withSlidesNoTransition(finalize);
      } else {
        finalize();
      }

      this.emit("active", {
        domIndex: this.currentIndex,
        realIndex: this.realIndex,
        slideEl: this.Components.Elements.slides[this.currentIndex],
        early: false,
        repositioned: didReposition,
      });

      this.emit("moved", {
        domIndex: this.currentIndex,
        realIndex: this.realIndex,
      });

      this.emit("interactionEnd", { type: "transition-complete" });
    }

    checkRepositionWithClassMirror() {
      const slides = this.Components.Elements.slides;
      const total = slides.length;
      let jumped = false;

      this._navigationLocked = true;

      const reposThreshold = Math.ceil(this.CLONE_COUNT / 2);

      if (this.currentIndex >= total - reposThreshold) {
        this.isRepositioning = true;
        jumped = true;

        const srcIndex = this.currentIndex;
        const newIndex =
          this.CLONE_COUNT + (this.currentIndex - (total - this.CLONE_COUNT));

        this._mirrorActiveClasses(srcIndex, newIndex);
        this.currentIndex = newIndex;

        this.list.style.transition = "none";
        this.updatePosition(false);
        this.list.offsetHeight;

        this.isRepositioning = false;
        this.realIndex = this.mapToReal(this.currentIndex);
        this.index = this.realIndex;
      }

      if (this.currentIndex <= reposThreshold - 1) {
        this.isRepositioning = true;
        jumped = true;

        const srcIndex = this.currentIndex;
        const newIndex =
          total - this.CLONE_COUNT - (this.CLONE_COUNT - this.currentIndex);

        this._mirrorActiveClasses(srcIndex, newIndex);
        this.currentIndex = newIndex;

        this.list.style.transition = "none";
        this.updatePosition(false);
        this.list.offsetHeight;

        this.isRepositioning = false;
        this.realIndex = this.mapToReal(this.currentIndex);
        this.index = this.realIndex;
      }

      this._navigationLocked = false;
      return jumped;
    }

    _mirrorActiveClasses(srcIndex, dstIndex) {
      const slides = this.Components.Elements.slides;
      const src = slides[srcIndex];
      const dst = slides[dstIndex];

      if (!src || !dst) return;

      const copyFlag = (cls) => {
        if (src.classList.contains(cls)) {
          dst.classList.add(cls);
        } else {
          dst.classList.remove(cls);
        }
      };

      copyFlag("is-active");
      copyFlag("is-activating");
      copyFlag("is-prev");
      copyFlag("is-next");
    }

    resizeTo(width, gap, padding) {
      if (!this._isFullyInitialized) return;

      this.slideWidth = width;
      this.gap = gap;
      this.padding = padding;

      const all = this.list.querySelectorAll(".splide__slide");
      all.forEach((el) => {
        el.style.width = this.slideWidth + "px";
        el.style.marginRight = this.gap + "px";
        el.style.flexShrink = "0";
        this._applySlideTransition(el);
        el.classList.add("centered");
      });

      this.track.style.paddingLeft = this.padding + "px";
      this.track.style.paddingRight = this.padding + "px";

      this._withSlidesNoTransition(() => {
        this.updatePosition(false);
        this.updateActiveSlide();
        this.updatePagination();
      });
    }

    destroy() {
      if (this._viewportObserver) {
        this._viewportObserver.disconnect();
        this._viewportObserver = null;
      }

      document.removeEventListener(
        "visibilitychange",
        this._onVisibilityChange
      );
      this.list.removeEventListener("transitionend", this._onTransitionEnd);

      this._isFullyInitialized = false;
      Utils.log("Carousel destroyed");
    }

    on(event, handler) {
      if (this.eventHandlers[event]) {
        this.eventHandlers[event].push(handler);
      }
    }

    emit(event, ...args) {
      if (this.eventHandlers[event]) {
        this.eventHandlers[event].forEach((h) => h(...args));
      }
    }
  }

  // ==================== DYNAMIC TEXT COLOR ====================
  class DynamicTextColor {
    constructor(config) {
      this.config = config;
      this.isSafari = Utils.isSafari();
      this.intervalId = null;
    }

    init() {
      if (!this.config.enabled) return;

      this.update();

      if (this.config.updateInterval > 0) {
        this.intervalId = setInterval(
          () => this.update(),
          this.config.updateInterval
        );
      }

      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", () => this.update());
      }

      let resizeTimeout;
      window.addEventListener(
        "resize",
        () => {
          if (resizeTimeout) clearTimeout(resizeTimeout);
          resizeTimeout = setTimeout(() => this.update(), 150);
        },
        { passive: true }
      );
    }

    update() {
      try {
        const elements = document.querySelectorAll(
          '[data-dynamic-color="true"]'
        );

        if (this.isSafari && this.config.safariWhiteText) {
          elements.forEach((element) => {
            element.style.color = "#ffffff";
          });
          return;
        }

        elements.forEach((element) => this.updateElement(element));
      } catch (error) {
        Utils.error("Error updating text colors:", error);
      }
    }

    updateElement(element) {
      const style = window.getComputedStyle(element);
      const backgroundImage = style.backgroundImage;

      let rgb;
      const hexColorMatch = backgroundImage.match(
        /#[a-f0-9]{6}|#[a-f0-9]{3}/i
      );

      if (hexColorMatch) {
        rgb = Utils.hexToRgb(hexColorMatch[0]);
      } else {
        const rgbColorMatch = backgroundImage.match(/rgb\(\d+,\s*\d+,\s*\d+\)/);
        if (rgbColorMatch) {
          rgb = Utils.parseRgb(rgbColorMatch[0]);
        }
      }

      if (!rgb) {
        const gradientStart = style.getPropertyValue("--gradient-start").trim();
        const gradientEnd = style.getPropertyValue("--gradient-end").trim();

        if (gradientStart && gradientStart.startsWith("rgb")) {
          rgb = Utils.parseRgb(gradientStart);
        } else if (gradientEnd && gradientEnd.startsWith("rgb")) {
          rgb = Utils.parseRgb(gradientEnd);
        }
      }

      if (!rgb) return;

      const bgLuminance = Utils.getLuminance(rgb.r, rgb.g, rgb.b);
      const blackLuminance = Utils.getLuminance(0, 0, 0);
      const whiteLuminance = Utils.getLuminance(255, 255, 255);

      const blackContrast = Utils.getContrastRatio(bgLuminance, blackLuminance);
      const whiteContrast = Utils.getContrastRatio(bgLuminance, whiteLuminance);

      const optimalColor =
        blackContrast - whiteContrast > this.config.contrastThreshold
          ? "#000000"
          : "#ffffff";

      element.style.color = optimalColor;
    }

    destroy() {
      if (this.intervalId) {
        clearInterval(this.intervalId);
        this.intervalId = null;
      }
    }
  }

  // ==================== CARD BACKGROUNDS ====================
  class CardBackgrounds {
    constructor(carousel, colorExtractor, config) {
      this.carousel = carousel;
      this.colorExtractor = colorExtractor;
      this.config = config;
      this.observer = null;
    }

    async init() {
      if (!this.config.enabled) return;

      const cards = Array.from(
        document.querySelectorAll(".splide__slide")
      );
      await this.processBatch(cards, 0);

      this.observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.addedNodes.length) {
            const newCards = Array.from(mutation.addedNodes).filter(
              (node) => node.classList?.contains("splide__slide")
            );
            if (newCards.length) {
              this.processBatch(newCards, 0);
            }
          }
        });
      });

      this.observer.observe(document.body, {
        childList: true,
        subtree: true,
      });
    }

    async processBatch(cards, startIndex) {
      const batch = cards.slice(
        startIndex,
        startIndex + this.config.batchSize
      );
      if (batch.length === 0) return;

      await Promise.all(
        batch.map(async (card) => {
          try {
            const img = card.querySelector("img");
            if (!img?.src) return;

            const colors = await this.colorExtractor.extractFromUrl(img.src);
            if (!colors) return;

            const slideContents = card.querySelector(".splide-slide-text");
            if (!slideContents) return;

            const black = "#000000";
            const vibrant = colors.vibrant;
            const darkenedColor = Utils.interpolateColor(
              black,
              vibrant,
              this.config.darkenWeight
            );

            slideContents.style.backgroundColor = darkenedColor;

            // Optional: text color adjustment
            const hexColor = colors.dmuted;
            if (hexColor) {
              const rgb = hexColor.match(/\w\w/g);
              const brightness = Math.round(
                (parseInt(rgb[0], 16) * 299 +
                  parseInt(rgb[1], 16) * 587 +
                  parseInt(rgb[2], 16) * 114) /
                  1000
              );
              const textColor = brightness > 128 ? "#000000" : "#FFFFFF";
              const textElements = slideContents.querySelectorAll(
                ".splide-slide-heading, .splide-slide-sub-heading"
              );
              textElements.forEach((el) => (el.style.color = textColor));
            }
          } catch (error) {
            Utils.warn("Error processing card:", error);
          }
        })
      );

      requestAnimationFrame(() => {
        this.processBatch(cards, startIndex + this.config.batchSize);
      });
    }

    destroy() {
      if (this.observer) {
        this.observer.disconnect();
        this.observer = null;
      }
    }
  }

  // ==================== GRADIENT ANIMATOR ====================
  class GradientAnimator {
    constructor(carousel, colorExtractor, config) {
      this.carousel = carousel;
      this.colorExtractor = colorExtractor;
      this.config = config;
      this.currentAnimation = null;
      this.lastImageSrc = null;

      this.initializeCSS();
    }

    initializeCSS() {
      const style = document.createElement("style");
      document.head.appendChild(style);

      try {
        style.sheet.insertRule(
          ".why-ais-scrolling-cards{background-image:linear-gradient(var(--gradient-start),var(--gradient-end))}"
        );
      } catch (e) {
        Utils.warn("Failed to insert gradient CSS rule");
      }

      document.documentElement.style.setProperty(
        "--gradient-start",
        "rgb(0,0,0)"
      );
      document.documentElement.style.setProperty("--gradient-end", "rgb(0,0,0)");
    }

    init() {
      if (!this.config.enabled || !window.gsap) {
        if (!window.gsap && this.config.enabled) {
          Utils.warn("GSAP not found, gradient animations disabled");
        }
        return;
      }

      this.handleActive();
      this.carousel.on("active", () => this.handleActive());
      this.carousel.on("moved", () => this.handleActive());
    }

    async handleActive() {
      const activeSlide =
        this.carousel.list?.querySelector(".splide__slide.is-active") ||
        this.carousel.Components?.Elements?.slides?.[
          this.carousel.currentIndex
        ];

      if (!activeSlide) return;

      const img = activeSlide.querySelector("img");
      let startHex = "#000000";
      let endHex = "#000000";

      if (img?.src) {
        if (img.src === this.lastImageSrc) return;
        this.lastImageSrc = img.src;

        try {
          const colors = await this.colorExtractor.extractFromUrl(img.src);
          if (colors) {
            startHex =
              colors[this.config.colorPairs.start] || colors.dominant;
            endHex = colors[this.config.colorPairs.end] || colors.dominant;
          }
        } catch (err) {
          Utils.warn("Gradient color extraction error:", err);
        }
      } else {
        const panel = activeSlide.querySelector(".splide-slide-text");
        if (panel) {
          const bg = getComputedStyle(panel).backgroundColor;
          const m = bg && bg.match(/\d+/g);
          if (m) {
            const asHex = (r, g, b) =>
              "#" +
              [r, g, b]
                .map((n) => Number(n).toString(16).padStart(2, "0"))
                .join("");
            const hex = asHex(m[0], m[1], m[2]);
            startHex = hex;
            endHex = hex;
          }
        }
        this.lastImageSrc = null;
      }

      this.updateGradient(startHex, endHex);
    }

    updateGradient(startHex, endHex) {
      const container = document.querySelector(".why-ais-scrolling-cards");
      if (!container) return;

      const cs = getComputedStyle(container);
      const parseRGB = (val, fallback = { r: 0, g: 0, b: 0 }) => {
        if (!val) return fallback;
        const m = val.match(/\d+/g);
        return m ? { r: +m[0], g: +m[1], b: +m[2] } : fallback;
      };

      const currentStartRGB = parseRGB(
        cs.getPropertyValue("--gradient-start").trim()
      );
      const currentEndRGB = parseRGB(
        cs.getPropertyValue("--gradient-end").trim()
      );

      const targetStart = Utils.hexToRgb(startHex);
      const targetEnd = Utils.hexToRgb(endHex);

      if (!targetStart || !targetEnd) return;

      if (this.currentAnimation && typeof this.currentAnimation.kill === "function") {
        this.currentAnimation.kill();
      }

      const tweenState = {
        sR: currentStartRGB.r,
        sG: currentStartRGB.g,
        sB: currentStartRGB.b,
        eR: currentEndRGB.r,
        eG: currentEndRGB.g,
        eB: currentEndRGB.b,
      };

      const rgbToString = (rgb) =>
        `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;

      this.currentAnimation = gsap.to(tweenState, {
        duration: this.config.duration,
        sR: targetStart.r,
        sG: targetStart.g,
        sB: targetStart.b,
        eR: targetEnd.r,
        eG: targetEnd.g,
        eB: targetEnd.b,
        ease: "power2.inOut",
        onUpdate: () => {
          container.style.setProperty(
            "--gradient-start",
            rgbToString({
              r: tweenState.sR,
              g: tweenState.sG,
              b: tweenState.sB,
            })
          );
          container.style.setProperty(
            "--gradient-end",
            rgbToString({ r: tweenState.eR, g: tweenState.eG, b: tweenState.eB })
          );
        },
      });
    }

    destroy() {
      if (this.currentAnimation && typeof this.currentAnimation.kill === "function") {
        this.currentAnimation.kill();
        this.currentAnimation = null;
      }
    }
  }

  // ==================== AUTOPLAY ====================
  class CarouselAutoplay {
    constructor(carousel, config) {
      this.carousel = carousel;
      this.config = config;
      this.intervalId = null;
      this.isPlaying = false;
      this.hasInitialAdvance = false;

      this.handleVisibilityChangeBound =
        this.handleVisibilityChange.bind(this);
    }

    init() {
      if (!this.config.enabled || !this.carousel) return;

      this.carousel.on("visibilityChange", this.handleVisibilityChangeBound);

      this.carousel.on("interactionStart", (data) => {
        if (data.type === "keyboard-spacebar") {
          this.toggle();
        }
      });

      if (this.carousel.isInViewport) {
        this.play();
      }
    }

    handleVisibilityChange(data) {
      if (data.isVisible) {
        this.play();
      } else {
        this.pause();
      }
    }

    toggle() {
      if (this.isPlaying) {
        this.pause();
      } else {
        this.play();
      }
    }

    play() {
      if (this.intervalId || !this.carousel.isInViewport) return;

      this.isPlaying = true;

      if (!this.hasInitialAdvance) {
        this.advance();
        this.hasInitialAdvance = true;
      }

      this.intervalId = setInterval(() => {
        this.advance();
      }, this.config.interval);

      Utils.log("Autoplay started");
    }

    pause() {
      this.isPlaying = false;
      clearInterval(this.intervalId);
      this.intervalId = null;
      Utils.log("Autoplay paused");
    }

    advance() {
      if (!this.carousel || !this.carousel.isInViewport) return;

      if (!this.carousel.isBusy()) {
        this.carousel.next();
      }
    }

    destroy() {
      this.pause();
    }
  }

  // ==================== MAIN LIBRARY CLASS ====================
  class InfiniteCarouselLibrary {
    constructor(userConfig = {}) {
      // Deep merge configuration
      this.config = this.mergeConfig(DEFAULT_CONFIG, userConfig);
      Utils.config = this.config;

      // Store instances
      this.carousel = null;
      this.colorExtractor = null;
      this.dynamicTextColor = null;
      this.cardBackgrounds = null;
      this.gradientAnimator = null;
      this.autoplay = null;

      Utils.log("Library initialized with config:", this.config);
    }

    mergeConfig(defaults, user) {
      const merged = { ...defaults };

      for (const key in user) {
        if (
          typeof user[key] === "object" &&
          !Array.isArray(user[key]) &&
          user[key] !== null
        ) {
          merged[key] = this.mergeConfig(defaults[key] || {}, user[key]);
        } else {
          merged[key] = user[key];
        }
      }

      return merged;
    }

    async init() {
      try {
        // Initialize color extractor
        if (this.config.colors.enabled) {
          this.colorExtractor = new ColorExtractor(this.config.colors);
          Utils.log("Color extractor initialized");
        }

        // Initialize carousel
        await this.initCarousel();

        // Initialize additional features
        if (this.carousel) {
          this.initFeatures();
        }

        Utils.log("Library fully initialized");
      } catch (error) {
        Utils.error("Initialization failed:", error);
        throw error;
      }
    }

    async initCarousel() {
      const tryInit = () => {
        const container = document.querySelector(this.config.carousel.selector);
        if (!container) {
          Utils.warn("Carousel container not found, retrying...");
          return false;
        }

        try {
          this.carousel = new InfiniteCarousel(
            this.config.carousel.selector,
            this.config.carousel
          );

          // Setup responsive handling
          this.setupResponsive();

          Utils.log("Carousel initialized");
          return true;
        } catch (error) {
          Utils.error("Carousel initialization failed:", error);
          return false;
        }
      };

      return new Promise((resolve) => {
        const attemptInit = (attempts = 0) => {
          if (tryInit()) {
            resolve();
            return;
          }

          if (attempts >= 10) {
            Utils.error("Failed to initialize carousel after 10 attempts");
            resolve();
            return;
          }

          const delay = Math.min(100 * Math.pow(1.5, attempts), 2000);
          setTimeout(() => attemptInit(attempts + 1), delay);
        };

        if (document.readyState === "loading") {
          document.addEventListener("DOMContentLoaded", () => {
            requestAnimationFrame(() => attemptInit());
          });
        } else {
          requestAnimationFrame(() => attemptInit());
        }
      });
    }

    initFeatures() {
      // Dynamic text color
      if (this.config.dynamicText.enabled) {
        this.dynamicTextColor = new DynamicTextColor(this.config.dynamicText);
        this.dynamicTextColor.init();
        Utils.log("Dynamic text color initialized");
      }

      // Card backgrounds
      if (this.config.cardBackgrounds.enabled && this.colorExtractor) {
        this.cardBackgrounds = new CardBackgrounds(
          this.carousel,
          this.colorExtractor,
          this.config.cardBackgrounds
        );

        this.carousel.on("mounted", () => {
          this.cardBackgrounds.init();
          Utils.log("Card backgrounds initialized");
        });
      }

      // Gradient animator
      if (this.config.gradients.enabled && this.colorExtractor) {
        this.gradientAnimator = new GradientAnimator(
          this.carousel,
          this.colorExtractor,
          this.config.gradients
        );

        this.carousel.on("mounted", () => {
          this.gradientAnimator.init();
          Utils.log("Gradient animator initialized");
        });
      }

      // Autoplay
      if (this.config.autoplay.enabled) {
        this.autoplay = new CarouselAutoplay(
          this.carousel,
          this.config.autoplay
        );

        this.carousel.on("mounted", () => {
          this.autoplay.init();
          Utils.log("Autoplay initialized");
        });
      }
    }

    setupResponsive() {
      let resizeTimeout;
      window.addEventListener("resize", () => {
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
          if (!this.carousel || !this.carousel._isFullyInitialized) return;

          const mobile = this.config.responsive.mobile;
          if (window.innerWidth <= mobile.breakpoint) {
            const width =
              typeof mobile.slideWidth === "string" &&
              mobile.slideWidth.includes("vw")
                ? Math.round(
                    (window.innerWidth *
                      parseFloat(mobile.slideWidth)) /
                      100
                  )
                : mobile.slideWidth;

            this.carousel.resizeTo(width, mobile.gap, mobile.padding);
          } else {
            this.carousel.resizeTo(
              this.config.carousel.slideWidth,
              this.config.carousel.gap,
              this.config.carousel.padding
            );
          }
        }, 100);
      });
    }

    // Public API
    getCarousel() {
      return this.carousel;
    }

    getColorExtractor() {
      return this.colorExtractor;
    }

    toggleAutoplay() {
      if (this.autoplay) {
        this.autoplay.toggle();
      }
    }

    destroy() {
      Utils.log("Destroying library");

      if (this.autoplay) this.autoplay.destroy();
      if (this.gradientAnimator) this.gradientAnimator.destroy();
      if (this.cardBackgrounds) this.cardBackgrounds.destroy();
      if (this.dynamicTextColor) this.dynamicTextColor.destroy();
      if (this.carousel) this.carousel.destroy();

      this.carousel = null;
      this.colorExtractor = null;
      this.dynamicTextColor = null;
      this.cardBackgrounds = null;
      this.gradientAnimator = null;
      this.autoplay = null;

      Utils.log("Library destroyed");
    }
  }

  // ==================== EXPORT ====================
  global.InfiniteCarouselLibrary = InfiniteCarouselLibrary;
  global.InfiniteCarousel = InfiniteCarousel; // Export core class separately
})(typeof window !== "undefined" ? window : this);
  1. Usage Example
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Infinite Carousel Example</title>

    <!-- CSS Dependency -->
    <link rel="stylesheet" href="infinite-carousel.css" />

    <!-- GSAP (required for gradient animations) -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
  </head>
  <body>
    <div class="why-ais-scrolling-cards">
      <div class="splide centered">
        <div class="splide__track">
          <ul class="splide__list">
            <li class="splide__slide">
              <img src="image1.jpg" alt="Slide 1" />
              <div class="splide-slide-text">
                <h3 class="splide-slide-heading">Slide 1</h3>
                <p class="splide-slide-sub-heading">Description</p>
              </div>
            </li>
            <!-- More slides -->
          </ul>
        </div>

        <!-- Navigation -->
        <button class="splide__arrow splide__arrow--prev">Previous</button>
        <button class="splide__arrow splide__arrow--next">Next</button>

        <!-- Pagination -->
        <ul class="splide__pagination">
          <li>
            <button class="splide__pagination__page"></button>
          </li>
          <!-- More pagination dots -->
        </ul>
      </div>
    </div>

    <!-- Library -->
    <script src="infinite-carousel.js"></script>

    <!-- Initialize -->
    <script>
      // Basic initialization with defaults
      const carousel = new InfiniteCarouselLibrary();
      carousel.init();

      // Or with custom configuration
      const customCarousel = new InfiniteCarouselLibrary({
        carousel: {
          selector: ".splide.centered",
          gap: 32,
          slideWidth: 380,
          speed: 800,
        },
        autoplay: {
          enabled: true,
          interval: 3000,
        },
        colors: {
          sampleSize: 150, // Higher quality color extraction
        },
        gradients: {
          duration: 1.2,
          colorPairs: {
            start: "lvibrant", // Light vibrant
            end: "dvibrant", // Dark vibrant
          },
        },
        debug: true, // Enable console logging
      });

      customCarousel.init();

      // Access carousel instance
      const carouselInstance = customCarousel.getCarousel();
      carouselInstance.on("moved", (data) => {
        console.log("Moved to slide:", data.realIndex);
      });
    </script>
  </body>
</html>
  1. Configuration Options Reference
{
  // Core carousel settings
  carousel: {
    selector: ".splide.centered",  // CSS selector for carousel container
    gap: 48,                        // Gap between slides (px)
    padding: 32,                    // Track padding (px)
    slideWidth: 420,                // Slide width (px)
    speed: 1000,                    // Transition speed (ms)
    easing: "cubic-bezier(...)",    // CSS easing function
    focusCenter: true,              // Center active slide
    mobileOffset: -17.5,            // Mobile positioning offset
    cloneCount: null                // Number of clones (auto if null)
  },

  // Autoplay
  autoplay: {
    enabled: true,                  // Enable autoplay
    interval: 2000,                 // Advance interval (ms)
    pauseOnHover: false             // Pause on hover (not yet implemented)
  },

  // Color extraction
  colors: {
    enabled: true,                  // Enable color extraction
    sampleSize: 100,                // Image downsample size
    cacheColors: true               // Cache extracted colors
  },

  // Dynamic text color
  dynamicText: {
    enabled: true,                  // Enable dynamic text color
    updateInterval: 3000,           // Update frequency (ms)
    contrastThreshold: 7,           // Contrast threshold for black text
    safariWhiteText: true           // Force white text in Safari
  },

  // Gradient animations
  gradients: {
    enabled: true,                  // Enable gradient animations
    duration: 0.9,                  // Animation duration (seconds)
    colorPairs: {
      start: "vibrant",             // Start color type
      end: "dvibrant"               // End color type
    }
    // Available color types:
    // dominant, vibrant, muted, lvibrant, lmuted,
    // dvibrant, dmuted, ldominant, ddominant
  },

  // Card backgrounds
  cardBackgrounds: {
    enabled: true,                  // Enable card backgrounds
    batchSize: 4,                   // Process cards in batches
    darkenWeight: 0.67              // Blend weight (0-1, 0=black, 1=vibrant)
  },

  // Responsive
  responsive: {
    mobile: {
      breakpoint: 478,              // Mobile breakpoint (px)
      slideWidth: "70vw",           // Slide width (number or vw string)
      gap: 48,
      padding: 64
    }
  },

  // Debug
  debug: false                      // Enable console logging
}

OZORDI avatar Oct 18 '25 05:10 OZORDI