Search code examples
javascripthtmlcarouselonloadonload-event

How to know if all onload Events already fired?


This question is a more specific and a superset of this question: How to know if window "load" event was fired already .

Use Case

So I have this use case where I need to make a lazy loaded CSS-only slideshow of pictures and for that I realized it thru onload Events which fire with the slide number, which fires after a delay only when the picture finished loading the picture in question. The problem is that the suggested answer shows 'complete' after the first onload Event fired, which is not fitting for me. So my question is - how do I detect whether all onload Events already fired?

The problem

All my slides go thru and afterwards the slideshow misbehaves. That's why I need to detect that condition so that I can control my slide behaviour after the fact and so fix this problem.

StackBlitz Link

https://stackblitz.com/edit/js-fsymew

GitHub Link

https://github.com/munchkindev/js-fsymew

My HTML:

<div style="margin-bottom: 30px;">
<img class="slider" src="graphics/slider1.webp" onload="delayedCarousel(1);">
<div class="slider-text" id="slidert1">Text here, you can just ignore this.</div>
<img loading="lazy" class="slider" src="graphics/slider2.webp" onload="delayedCarousel(2);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider3.webp" onload="delayedCarousel(3);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider4.webp" onload="delayedCarousel(4);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider5.webp" onload="delayedCarousel(5);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider6.webp" onload="delayedCarousel(1);" style="display:none">
<button class="slider-button slider-black slider-display-left" onclick="changeSlide(-1);">&#10094;</button>
<button class="slider-button slider-black slider-display-right" onclick="changeSlide(1);">&#10095;</button>
</div>

JS:

function carousel(n) {
  var i;
  var x = document.getElementsByClassName("slider");
  if (n === 6) { delayedCarousel(1); }
  //for (i = 0; i < x.length; i++) {
    x[n].style.display = "block";
    if (n === 1) x[5].style.display = "none";
  //}
  slideIndex++;
  //if (n > x.length) {/*slideIndex = 1*/
      x[n-1].style.display = "none";
  //setTimeout(carousel, 5000); // Change image every 5 seconds
}
function delayedCarousel(n) {
    setTimeout(function(){
      carousel(n);
    }, 3000);
    }

function changeSlide(n) {
    showDivs(slideIndex += n);
}

function showDivs(n) {
    var i;
    var x = document.getElementsByClassName("slider");
    if (n > x.length) { slideIndex = 1 }
    if (n < 1) { slideIndex = x.length }
    for (i = 0; i < x.length; i++) {
        x[i].style.display = "none";
        x[i].style.width = "100%";
    }
    x[slideIndex - 1].style.display = "block";
}

Try 2

HTML:

<div style="margin-bottom: 30px;">
<img class="slider" src="graphics/slider1.webp" onload="delayedCarousel(1);">
<div class="slider-text" id="slidert1">Ignore this text, it's irrelevant.</div>
<img loading="lazy" class="slider" src="graphics/slider2.webp" onload="delayedCarousel(2);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider3.webp" onload="delayedCarousel(3);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider4.webp" onload="delayedCarousel(4);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider5.webp" onload="delayedCarousel(5);" style="display:none">
<img loading="lazy" class="slider" src="graphics/slider6.webp" onload="loopedCarousel(1);" style="display:none">
<button class="slider-button slider-black slider-display-left" onclick="changeSlide(-1);">&#10094;</button>
<button class="slider-button slider-black slider-display-right" onclick="changeSlide(1);">&#10095;</button>
</div>

JS:

//   function startSliderLoop() {
//            var i = 1;
//        setInterval(function() {
//            location.href = "/#slide-"+i;
//   //document.addEventListener('click', function (event) {

//  // If the clicked element does not have and is not contained by an element with the .click-me class, ignore it
//  //if (!event.target.closest('#slideButton'+ i )) return;

//  // Otherwise, do something...
//    if (i <= 5)
//    i++;
//    else
//    i = 1;
//}, 3000)
//    }
//    document.addEventListener("DOMContentLoaded", (event) => {
//    startSliderLoop();
//});
var slideIndex = 0;
var sliders = document.querySelector('.slider');
//document.querySelector('.slider').forEach(function(img){
//      img.addEventListener('load', carousel());
//    });

function carousel(n) {
  var i;
  var x = document.getElementsByClassName("slider");
  if (n === 6) { delayedCarousel(1); }
  //for (i = 0; i < x.length; i++) {
    x[n].style.display = "block";
    if (n === 1) x[5].style.display = "none";
  //}
  slideIndex++;
  //if (n > x.length) {/*slideIndex = 1*/
      x[n-1].style.display = "none";
  //setTimeout(carousel, 5000); // Change image every 5 seconds
}

function loopedCarousel(n) {
  var i;
  var x = document.getElementsByClassName("slider");
  if (n === 5) { n=1; loopedCarousel(1); }
  //for (i = 0; i < x.length; i++) {
    x[n].style.display = "block";
    if (n === 1) x[5].style.display = "none";
  //}
  slideIndex++;
  //if (n > x.length) {/*slideIndex = 1*/
      x[n-1].style.display = "none";
  setTimeout(loopedCarousel(n+1), 5000); // Change image every 5 seconds
}
function delayedCarousel(n) {
    setTimeout(function(){
      carousel(n);
    }, 3000);
    }


Solution

  • I would use promises to listen to image load events, this way you can wait for all the promises to complete before initiating your carousel.

    For example, if you had the below html:

    <img src="https://picsum.photos/100/300" />
    <img src="https://picsum.photos/200/300" />
    <img src="https://picsum.photos/300/300" />
    <img src="https://picsum.photos/400/300" />
    <img src="https://picsum.photos/500/300" />
    

    You could wait for all these to load with something like:

    const promises = [];
    document.querySelectorAll('img').forEach((img) => {
      const promise = new Promise((resolve, reject) => {
        img.addEventListener('load', resolve);
        img.addEventListener('error', reject);
      });
      promises.push(promise);
    });
    
    Promise.all(promises).then(() => {
      console.log('All images successfully loaded.');
    });
    

    I've created working example for you below. You'll notice that before all the images have loaded the carousel will display a red border. Once loaded the border color changes to green.

    class CarouselController {
    
        /**
         * @param {object} settings
         */
        constructor(settings) {
            this.carousel = settings.element;
            this.current = 0;
    
            if (!this.carousel) {
                throw 'A carousel element is required. For example: new CarouselController({ element: document.getElementById(\'carousel\') })';
            }
    
            this.settings = {
                loop: 'loop' in settings ? settings.loop : true,
                delay: 'delay' in settings ? parseInt(settings.delay) : 5000
            };
        }
    
        /**
         * Get the carousel container element.
         * @returns {Element}
         */
        getCarousel() {
            return this.carousel;
        }
    
        /**
         * Get a setting value.
         * @param {string} name
         * @param defaultValue
         * @returns {*}
         */
        getSetting(name, defaultValue) {
            return name in this.settings ? this.settings[name] : defaultValue;
        }
    
        /**
         * Get all the children (slides) elements.
         * @returns {Element[]}
         */
        getSlides() {
            return Array.from(this.getCarousel().children);
        }
    
        /**
         * Get a specific slide by index.
         * @param {int} index
         * @returns {Element|null}
         */
        getSlide(index) {
            return this.getSlides()[index];
        }
    
        /**
         * Show a specific slide by index.
         * @param {int} index
         * @returns {int}
         */
        goTo(index) {
            const slides = this.getSlides();
            const slide = this.getSlide(index);
    
            if (slide) {
                slides.forEach((el) => {
                    el.classList.remove('active');
                });
    
                slide.classList.add('active');
    
                this.current = slides.indexOf(slide);
            }
    
            return this.current;
        }
    
        /**
         * Show the next slide (if has one).
         */
        next() {
            let replay = false;
    
            // Check if carousel is looping through slides automatically.
            if (this.playing) {
                replay = true;
            }
    
            const slides = this.getSlides();
            let nextIndex = this.current + 1;
    
            // If the next slide is greater than the total, reset to 0 if looping else use -1 to stop `goTo` method.
            if (nextIndex > (slides.length - 1)) {
                if (this.getSetting('loop')) {
                    nextIndex = 0;
                } else {
                    nextIndex = -1;
                }
            }
                    
            // Only go to slide if next index is valid.
            if (nextIndex >= 0) {
                this.goTo(nextIndex);
    
                // Continue with auto play.
                if (replay) {
                    this.play();
                }
            }
        }
    
        /**
         * Show the previous slide (if has one).
         */
        previous() {
            let replay = false;
    
            // Check if carousel is looping through slides automatically.
            if (this.playing) {
                replay = true;
            }
    
            const slides = this.getSlides();
            let prevIndex = this.current - 1;
    
            // If the prev slide is less than 0, reset to the last slide if looping else use -1 to stop `goTo` method.
            if (prevIndex < 0) {
                if (this.getSetting('loop')) {
                    prevIndex = slides.length - 1;
                } else {
                    prevIndex = -1;
                }
            }
    
            // Only go to slide if next index is valid.
            if (prevIndex >= 0) {
                this.goTo(prevIndex);
    
                // Continue with auto play.
                if (replay) {
                    this.play();
                }
            }
        }
    
        /**
         * Automatically go to the next slide (or start if loop is true).
         * @returns {number}
         */
        play() {
            this.stop();
    
            this.goTo(this.current);
    
            this.playing = setInterval(() => {
                this.next();
            }, this.getSetting('delay'));
    
            return this.playing;
        }
    
        /**
         * Stop the automatic carousel if running.
         */
        stop() {
            if (this.playing) {
                clearInterval(this.playing);
            }
        }
    
    
    }
    
    /**
     * Get the carousel container element.
     * @type {Element}
     */
    const carouselContainer = document.querySelector('.carousel-container');
    
    /**
     * Create a new controller instance for our carousel.
     * @type {CarouselController}
     */
    const carousel = new CarouselController({
        element: carouselContainer.querySelector('.carousel'),
        loop: true,
        delay: 3000
    });
    
    /**
     * Build an array of image load promises.
     * @type {Promise[]}
     */
    const imagePromises = [];
    carousel.getCarousel().querySelectorAll('img').forEach((el) => {
        const promise = new Promise((resolve, reject) => {
            const image = new Image();
            image.addEventListener('load', resolve);
            image.addEventListener('error', reject);
            image.src = el.src;
        });
        imagePromises.push(promise);
    });
    
    /**
     * Wait for all image promises to complete (even if failed) and initiate the carousel.
     * @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
     */
    Promise.allSettled(imagePromises).then(() => {
    
        carouselContainer.classList.add('loaded');
    
        /**
         * Start auto playing with settings defined in the constructor.
         */
        carousel.play();
    
        /**
         * Show previous slide (if has one) when clicking previous button.
         */
        document.querySelector('.carousel-prev').addEventListener('click', function(event) {
            event.preventDefault();
            carousel.previous();
        });
    
        /**
         * Show next slide (if has one) when clicking next button.
         */
        document.querySelector('.carousel-next').addEventListener('click', function(event) {
            event.preventDefault();
            carousel.next();
        });
    
    });
    /* Container styles BEFORE carousel has loaded */
    .carousel-container {
      opacity: 0.3;
      pointer-events: none;
      border: 2px solid red;
    }
    
    /* Container styles AFTER carousel has loaded */
    .carousel-container.loaded {
      opacity: 1;
      pointer-events: auto;
      border-color: green;
    }
    
    /* Hide all carousel slides by default */
    .carousel-container .carousel > * {
      display: none;
    }
    
    /* Only show the active carousel slide */
    .carousel-container .carousel > *.active {
      display: block;
    }
    <div class="carousel-container">
      <div class="carousel">
        <img src="https://picsum.photos/100/300" />
        <img src="https://picsum.photos/200/300" />
        <img src="https://picsum.photos/300/300" />
        <img src="https://picsum.photos/400/300" />
        <img src="https://picsum.photos/500/300" />
      </div>
      <button class="carousel-prev">
        &#10094;
      </button>
      <button class="carousel-next">
        &#10095;
      </button>
    </div>

    Or you can view it on JSFiddle here: https://jsfiddle.net/thelevicole/b13gpyfd/2/


    Edit

    Below is updated example of how to lazy load slide images only when the slide is in view instead of waiting for all images to load on init.

    https://jsfiddle.net/thelevicole/b13gpyfd/3/

    class CarouselController {
    
        defaultSettings = {
            loop: true,
            delay: 5000,
            autoplay: true
        }
    
        /**
         * @param {object} settings
         */
        constructor(settings) {
            this.carousel = settings.element;
            delete settings.element;
    
            this.current = 0;
            this.hooks = {};
            this.settings = settings;
    
            if (!this.carousel) {
                throw 'A carousel element is required. For example: new CarouselController({ element: document.getElementById(\'carousel\') })';
            }
    
            /**
             * Sanitize `loop` setting
             */
            this.addFilter('setting.loop', value => {
                return String(value).toLowerCase() === 'true';
            });
    
            /**
             * Sanitize `delay` setting
             */
            this.addFilter('setting.delay', value => parseInt(value));
    
            /**
             * Sanitize `autoplay` setting
             */
            this.addFilter('setting.autoplay', value => {
                return String(value).toLowerCase() === 'true';
            });
    
            // Autoplay on init.
            if (this.getSetting('autoplay')) {
                this.play();
            }
        }
    
        /**
         * Get the carousel container element.
         * @returns {Element}
         */
        getCarousel() {
            return this.carousel;
        }
    
        /**
         * Get a setting value.
         * @param {string} name
         * @param defaultValue
         * @returns {*}
         */
        getSetting(name, defaultValue) {
            if (!defaultValue && name in this.defaultSettings) {
                defaultValue = this.defaultSettings[name]
            }
    
            /**
             * Apply value filters.
             * @example carousel.addFilter('setting.delay', function(value) { return value + 500; });
             */
            return this.applyFilters(`setting.${name}`, name in this.settings ? this.settings[name] : defaultValue);
        }
    
        /**
         * Get hooks by type and name. Ordered by priority.
         * @param {string} type
         * @param {string} name
         * @returns {array}
         */
        getHooks(type, name) {
            let hooks = [];
    
            if (type in this.hooks) {
                let localHooks = this.hooks[type];
                localHooks = localHooks.filter(el => el.name === name);
                localHooks = localHooks.sort((a, b) => a.priority - b.priority);
                hooks = hooks.concat(localHooks);
            }
    
            return hooks;
        }
    
        /**
         * Add a hook.
         * @param {string} type
         * @param {object} hookMeta
         */
        addHook(type, hookMeta) {
    
            // Create new local hook type array.
            if (!(type in this.hooks)) {
                this.hooks[type] = [];
            }
    
            this.hooks[type].push(hookMeta);
        }
    
        /**
         * Add action listener.
         * @param {string} action Name of action to trigger callback on.
         * @param {function} callback
         * @param {number} priority
         */
        addAction(action, callback, priority = 10) {
            this.addHook('actions', {
                name: action,
                callback: callback,
                priority: priority
            });
        }
    
        /**
         * Trigger an action.
         * @param {string} name Name of action to run.
         * @param {*} args Arguments passed to the callback function.
         */
        doAction(name, ...args) {
            this.getHooks('actions', name).forEach(hook => {
               hook.callback(...args);
            });
        }
    
        /**
         * Register filter.
         * @param {string} filter Name of filter to trigger callback on.
         * @param {function} callback
         * @param {number} priority
         */
        addFilter(filter, callback, priority = 10) {
            this.addHook('filters', {
                name: filter,
                callback: callback,
                priority: priority
            });
        }
    
        /**
         * Apply all named filters to a value.
         * @param {string} name Name of action to run.
         * @param {*} value The value to be mutated.
         * @param {*} args Arguments passed to the callback function.
         * @returns {*}
         */
        applyFilters(name, value, ...args) {
            this.getHooks('filters', name).forEach(hook => {
                value = hook.callback(value, ...args);
            });
    
            return value;
        }
    
        /**
         * Get all the children (slides) elements.
         * @returns {Element[]}
         */
        getSlides() {
            return Array.from(this.getCarousel().children);
        }
    
        /**
         * Get a specific slide by index.
         * @param {int} index
         * @returns {Element|null}
         */
        getSlide(index) {
            return this.getSlides()[index];
        }
    
        /**
         * Show a specific slide by index.
         * @param {int} index
         * @returns {int}
         */
        goTo(index) {
            const slides = this.getSlides();
            const slide = this.getSlide(index);
    
            if (slide) {
                slides.forEach((el) => {
                    el.classList.remove('active');
                });
    
                slide.classList.add('active');
    
                this.current = slides.indexOf(slide);
    
                /**
                 * Trigger goto event.
                 * @example carousel.addAction('goto', function(slide, index) { ... });
                 */
                this.doAction('goto', slide, this.current);
            }
    
            return this.current;
        }
    
        /**
         * Show the next slide (if has one).
         */
        next() {
            let replay = false;
    
            // Check if carousel is looping through slides automatically.
            if (this.playing) {
                replay = true;
            }
    
            const slides = this.getSlides();
            let nextIndex = this.current + 1;
    
            // If the next slide is greater than the total, reset to 0 if looping else use -1 to stop `goTo` method.
            if (nextIndex > (slides.length - 1)) {
                if (this.getSetting('loop')) {
                    nextIndex = 0;
                } else {
                    nextIndex = -1;
                }
            }
    
            // Only go to slide if next index is valid.
            if (nextIndex >= 0) {
                this.goTo(nextIndex);
    
                // Continue with auto play.
                if (replay) {
                    this.play();
                }
            }
        }
    
        /**
         * Show the previous slide (if has one).
         */
        previous() {
            let replay = false;
    
            // Check if carousel is looping through slides automatically.
            if (this.playing) {
                replay = true;
            }
    
            const slides = this.getSlides();
            let prevIndex = this.current - 1;
    
            // If the prev slide is less than 0, reset to the last slide if looping else use -1 to stop `goTo` method.
            if (prevIndex < 0) {
                if (this.getSetting('loop')) {
                    prevIndex = slides.length - 1;
                } else {
                    prevIndex = -1;
                }
            }
    
            // Only go to slide if next index is valid.
            if (prevIndex >= 0) {
                this.goTo(prevIndex);
    
                // Continue with auto play.
                if (replay) {
                    this.play();
                }
            }
        }
    
        /**
         * Automatically go to the next slide (or start if loop is true).
         * @returns {number}
         */
        play() {
            this.stop();
    
            this.goTo(this.current);
    
            this.playing = setInterval(() => {
                this.next();
            }, this.getSetting('delay'));
    
            return this.playing;
        }
    
        /**
         * Stop the automatic carousel if running.
         */
        stop() {
            if (this.playing) {
                clearInterval(this.playing);
            }
        }
    
    
    }
    
    /**
     * Get the carousel container element.
     * @type {Element}
     */
    const carouselContainer = document.querySelector('.carousel-container');
    
    /**
     * Create a new controller instance for our carousel.
     * @type {CarouselController}
     */
    const carousel = new CarouselController({
        element: carouselContainer.querySelector('.carousel'),
        loop: true,
        delay: 3000,
        autoplay: true
    });
    
    /**
     * Lazy load each image only when the slide is in view.
     */
    carousel.addAction('goto', function(slide, index) {
        let images = [];
    
        if (slide.tagName.toLowerCase() === 'img') {
            images.push(slide);
        } else {
            images.concat(slide.querySelectorAll('img'));
        }
    
        images.forEach((img) => {
            if (!img.src && img.dataset.src) {
                img.src = img.dataset.src;
            }
        });
    });
    
    /**
     * Show previous slide (if has one) when clicking previous button.
     */
    document.querySelector('.carousel-prev').addEventListener('click', function(event) {
        event.preventDefault();
        carousel.previous();
    });
    
    /**
     * Show next slide (if has one) when clicking next button.
     */
    document.querySelector('.carousel-next').addEventListener('click', function(event) {
        event.preventDefault();
        carousel.next();
    });
    /* Container styles */
    .carousel-container {}
    
    /* Hide all carousel slides by default */
    .carousel-container .carousel > * {
      display: none;
    }
    
    /* Only show the active carousel slide */
    .carousel-container .carousel > *.active {
      display: block;
    }
    <div class="carousel-container">
      <div class="carousel">
        <img data-src="https://picsum.photos/100/300" />
        <img data-src="https://picsum.photos/200/300" />
        <img data-src="https://picsum.photos/300/300" />
        <img data-src="https://picsum.photos/400/300" />
        <img data-src="https://picsum.photos/500/300" />
      </div>
      <button class="carousel-prev">
        &#10094;
      </button>
      <button class="carousel-next">
        &#10095;
      </button>
    </div>

    I've added a hook mechanism to allow callbacks to run everytime the slide changes carousel.addAction('goto', (slide, index) => { ... });