← back to Dw Theme Compact Toolbar

snippets/section.flex-pdp.media-gallery.liquid

839 lines

{% doc %}
  Copyright © 2025 Archetype Themes LP. All rights reserved.

  Renders a product media gallery with thumbnail navigation and lightbox functionality.

  This component displays product media in a main gallery view with thumbnail navigation.
  It supports images, videos, external videos, and 3D models. The featured image is
  displayed first, followed by all other media. Images can be opened in a lightbox
  overlay with navigation controls. Thumbnails can be positioned around the main gallery.

  @param {object} product - A product object
  @param {string} [image_aspect_ratio] - The aspect ratio for media containers (default: '1/1')
  @param {string} [thumbnail_position] - Position of thumbnails: 'top', 'left', 'right', 'bottom' (default: 'bottom')
  @param {boolean} [autoplay] - Autoplay the video (default: false)
  @param {boolean} [loop] - Loop the video (default: false)

  @example
  {% render 'section.flex-pdp.media-gallery', product: product, thumbnail_position: 'left' %}
{% enddoc %}

{%  liquid
  assign image_aspect_ratio = image_aspect_ratio | default: block.settings.image_aspect_ratio | default: '1/1'
  assign thumbnail_position = thumbnail_position | default: 'left'
  assign autoplay = autoplay | default: block.settings.enable_video_autoplay, allow_false: true | default: false, allow_false: true
  assign loop = loop | default: block.settings.enable_video_looping, allow_false: true | default: false, allow_false: true

  if image_aspect_ratio == '1/1'
    assign video_aspect_ratio = 'square'
  elsif image_aspect_ratio == '16/9'
    assign video_aspect_ratio = 'landscape'
  elsif image_aspect_ratio == '9/16'
    assign video_aspect_ratio = 'portrait'
  else
    assign video_aspect_ratio = 'landscape'
  endif
%}

{%- capture id -%}{% render 'utility.id', prefix: 'media-gallery' %}{%- endcapture -%}

{%- assign featured_media = product.selected_or_first_available_variant.featured_media | default: product.featured_media -%}
{%- assign group = 'product-gallery-' | append: id -%}

{% capture main_gallery %}
  {% if product.media.size > 0 %}
    {% assign is_active = false %}

    {% if product.selected_or_first_available_variant.image != blank %}
      {% assign is_active = true %}
      {% capture image_content %}
        {{ product.selected_or_first_available_variant.image | image_url: width: 2400 | image_tag:  alt: media.alt }}
      {% endcapture %}

      <div class="media-gallery__slide media-gallery__slide--active" data-media-id="{{ product.selected_or_first_available_variant.image.id }}" data-media-type="image">
        {%- assign group = 'product-gallery-' | append: id -%}
        {% render 'overlay.lightbox', slot: image_content, group: group %}
      </div>
    {% endif %}

    {% for media in product.media %}
      {% liquid
        if forloop.first and is_active == false
          assign is_active = true
        else
          assign is_active = false
        endif
      %}

      {% unless media == product.selected_or_first_available_variant.image %}
        <div class="media-gallery__slide{% if is_active %} media-gallery__slide--active{% endif %}" data-media-id="{{ media.id }}" data-media-type="{{ media.media_type }}">
          {% case media.media_type %}
            {% when 'image' %}

                {% capture image_content %}
                  {{ media | image_url: width: 2400 | image_tag: loading: 'lazy', alt: media.alt }}
                {% endcapture %}
                {% render 'overlay.lightbox', slot: image_content, group: group %}

            {% when 'video' %}
              {% render 'element.video', video: media, media_crop: video_aspect_ratio, autoplay: autoplay, loop: loop %}
            {% when 'external_video' %}
              {% render 'element.video', external_video: media, media_crop: video_aspect_ratio, autoplay: autoplay, loop: loop %}
            {% when 'model' %}
              {% render 'element.model', media: media, autoplay: autoplay %}
          {% endcase %}
        </div>
      {% endunless %}
    {% endfor %}
  {% else %}
    <div class="media-gallery__slide media-gallery__slide--active">
      {% render 'element.placeholder', name: 'product-1' %}
    </div>
  {% endif %}
{% endcapture %}

{% capture thumbnail_navigation %}
  {% if product.media.size > 1 %}
    {% capture thumbnail_buttons %}
      {% assign is_active = false %}

      {% if product.selected_or_first_available_variant.image != blank %}
        {% assign media = product.selected_or_first_available_variant.image %}
        {% assign is_active = true %}
        {% capture image_content %}
          {{ product.selected_or_first_available_variant.image | image_url: width: 2400 | image_tag:  alt: media.alt }}
        {% endcapture %}

        <button
          class="media-gallery__thumb media-gallery__thumb--active"
          data-media-id="{{ media.id }}"
          data-media-type="{{ media.media_type }}"
          aria-label="View {{ media.alt | default: 'image' | escape }}"
        >
          <div class="media-gallery__thumb-image">

            {{ media | image_url: width: 240 | image_tag }}
          </div>
        </button>
      {% endif %}
      {% for media in product.media %}
        {% liquid
          if forloop.first and is_active == false
            assign is_active = true
          else
            assign is_active = false
          endif
        %}

        {% unless media == product.selected_or_first_available_variant.image %}
          <button
            class="media-gallery__thumb{% if is_active %} media-gallery__thumb--active{% endif %}"
            data-media-id="{{ media.id }}"
            data-media-type="{{ media.media_type }}"
            aria-label="View {{ media.alt | default: 'image' | escape }}"
          >
            <div class="media-gallery__thumb-image">
              {%- if media.media_type == 'video' or media.media_type == 'external_video' -%}
                <span class="media-gallery__thumb-icon">
                  {% render 'element.icon', name: 'play' %}
                </span>
              {%- endif -%}
              {%- if media.media_type == 'model' -%}
                <span class="media-gallery__thumb-icon">
                  {% render 'element.icon', name: '3d' %}
                </span>
              {%- endif -%}

              {{ media.preview_image | image_url: width: 240 | image_tag }}
            </div>
          </button>
        {% endunless %}
      {% endfor %}
    {% endcapture %}

    {% liquid
      assign stack_horizontal = true
      if thumbnail_position == 'left' or thumbnail_position == 'right'
        assign stack_horizontal = false
      endif
      assign thumbnail_classes = 'media-gallery__thumbnails media-gallery__thumbnails--' | append: thumbnail_position
    %}

    {% render 'layout.stack',
      horizontal_xs: true,
      horizontal_lg: stack_horizontal,
      gap: 'xs',
      wrap: 'nowrap',
      classlist: thumbnail_classes,
      slot: thumbnail_buttons
    %}
  {% endif %}
{% endcapture %}

{% liquid
  assign is_horizontal = false
  assign is_reverse = false
  assign align = 'center'

  if thumbnail_position == 'left' or thumbnail_position == 'right'
    assign is_horizontal = true
    assign align = 'start'
  endif

  if thumbnail_position == 'right' or thumbnail_position == 'bottom'
    assign is_reverse = true
  endif

  assign gallery_classes = 'media-gallery media-gallery--' | append: thumbnail_position
  assign gallery_attributes = 'id="' | append: id | append: '" data-media-gallery'
%}

{% capture gallery_content %}
  {{ thumbnail_navigation }}

  <div class="media-gallery__main" data-aspect-ratio="{{ image_aspect_ratio }}">
    {{ main_gallery }}

    {% if product.media.size > 1 %}
      {% render 'element.button',
        type: 'button',
        icon: 'chevron-left',
        classlist: 'media-gallery__nav media-gallery__nav--prev',
        attributes: 'aria-label="Previous image"',
        inverted: true
      %}
      {% render 'element.button',
        type: 'button',
        icon: 'chevron-right',
        classlist: 'media-gallery__nav media-gallery__nav--next',
        attributes: 'aria-label="Next image"',
        inverted: true
      %}
    {% endif %}
  </div>
{% endcapture %}

<media-gallery class="media-gallery media-gallery--{{ thumbnail_position }}" id="{{ id }}">
  {% render 'layout.stack',
    horizontal_xs: false,
    horizontal_lg: is_horizontal,
    reverse_xs: true,
    reverse_lg: is_reverse,
    gap: 'sm',
    align: align,
    slot: gallery_content
  %}
</media-gallery>

{% stylesheet %}
  .media-gallery {
    --media-gallery-thumb-size: 80px;
    --media-gallery-thumb-gap: var(--gap-size-xs);

    display: block;
    position: relative;
    overflow: hidden;
  }

  .media-gallery__main {
    position: relative;
    width: 100%;
    flex: 1;
    overflow: hidden;
    min-height: 400px;
    aspect-ratio: 1;
    touch-action: pan-y pinch-zoom;
    transition: height 0.3s ease-in-out;
  }

  /* Disable transition when setting height programmatically */
  .media-gallery__main--no-transition {
    transition: none;
  }

  .media-gallery__nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 10;
    opacity: 0.7;
    transition: opacity 0.2s ease;
  }

  .media-gallery__main:hover .media-gallery__nav {
    opacity: 1;
  }

  .media-gallery__nav--prev {
    left: var(--gap-size-sm);
  }

  .media-gallery__nav--next {
    right: var(--gap-size-sm);
  }

  .media-gallery__nav:focus-visible {
    opacity: 1;
  }

  .media-gallery__slide {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    z-index: 1;
  }

  .media-gallery__slide--is-animating {
    transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
  }

  .media-gallery__slide--active {
    opacity: 1;
    transform: translateX(0);
    z-index: 2;
  }

  .media-gallery__slide--prev {
    transform: translateX(-100%);
  }

  .media-gallery__slide--next {
    transform: translateX(100%);
  }

  .media-gallery__slide img:not(.overlay-lightbox__overlay img) {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .media-gallery__thumbnails {
    display: block;
    width: fit-content;
    max-width: 100%;

    max-height: 650px;
    scrollbar-width: thin;
    scrollbar-color: var(--color-border) transparent;
  }

  .media-gallery__thumbnails--top,
  .media-gallery__thumbnails--bottom {
    overflow-x: auto;
  }

  .media-gallery__thumbnails--left,
  .media-gallery__thumbnails--right {
    overflow-y: auto;
  }

  /* Webkit scrollbar styling */
  .media-gallery__thumbnails::-webkit-scrollbar {
    height: 4px;
    width: 4px;
  }

  .media-gallery__thumbnails::-webkit-scrollbar-track {
    background: transparent;
  }

  .media-gallery__thumbnails::-webkit-scrollbar-thumb {
    background: var(--color-border);
  }

  .media-gallery__thumbnails::-webkit-scrollbar-thumb:hover {
    background: var(--color-text-muted);
  }

  .media-gallery__thumb {
    position: relative;
    border: 2px solid transparent;
    background: none;
    padding: 0;
    cursor: pointer;
    overflow: hidden;
    transition: border-color 0.2s ease;
    flex-shrink: 0;
    width: var(--media-gallery-thumb-size);
    height: var(--media-gallery-thumb-size);
  }

  .media-gallery__thumb--active {
    border-color: var(--color-primary);
  }

  .media-gallery__thumb:hover {
    border-color: var(--color-primary);
    opacity: 0.8;
  }

  .media-gallery__thumb:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
  }

  .media-gallery__thumb-image {
    position: relative;
    width: 100%;
    max-width: var(--media-gallery-thumb-size);
    max-height: var(--media-gallery-thumb-size);
    height: var(--media-gallery-thumb-size);
    aspect-ratio: 1;
  }

  .media-gallery__thumb-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .media-gallery__thumb-icon {
    display: flex;
    position: absolute;
    top: var(--gap-size-2xs);
    right: var(--gap-size-2xs);
    background: var(--color-primary);
    color: var(--color-secondary);
    border-radius: 0;
    padding: var(--gap-size-2xs);
    z-index: 1;
  }

  .media-gallery__thumb-icon .element-icon {
    width: 12px;
    height: 12px;
  }


  .media-gallery .shopify-model-viewer-ui,
  .media-gallery .shopify-model-viewer-ui model-viewer {
      width: 100%;
      height: 100%;
  }

  /* Responsive adjustments */
  @media (max-width: 768px) {
    .media-gallery__main {
      min-height: 300px;
    }


    .media-gallery__nav {
      opacity: 1;
    }
  }
{% endstylesheet %}

{% style %}
  #{{ id }} .media-gallery__main {
    aspect-ratio: {{ image_aspect_ratio }};
  }
{% endstyle %}

{% javascript %}
  class MediaGalleryElement extends HTMLElement {
    constructor() {
      super();
      this.slides = this.querySelectorAll('.media-gallery__slide');
      this.thumbs = this.querySelectorAll('.media-gallery__thumb');
      this.prevBtn = this.querySelector('.media-gallery__nav--prev');
      this.nextBtn = this.querySelector('.media-gallery__nav--next');
      this.mainContainer = this.querySelector('.media-gallery__main');
      this.currentIndex = 0;
      this.isAnimating = false;
      this.isNaturalAspectRatio = this.mainContainer?.getAttribute('data-aspect-ratio') === 'initial';

      // Touch/swipe handling
      this.touchStartX = 0;
      this.touchStartY = 0;
      this.touchEndX = 0;
      this.touchEndY = 0;
      this.minSwipeDistance = 50;
    }

    connectedCallback() {
      this.init();
    }

    init() {
      // Adjust container height for natural aspect ratio
      if (this.isNaturalAspectRatio) {
        this.adjustContainerHeight(true); // Disable transition for initial setup

        // Set up resize observer for images that might load asynchronously
        this.setupResizeObserver();
      }

      // Add click handlers to thumbnails
      this.thumbs.forEach((thumb, index) => {
        thumb.addEventListener('click', () => {
          const direction = index > this.currentIndex ? 'next' : 'prev';
          this.goToSlide(index, direction);
        });
      });

      // Add click handlers to navigation buttons
      if (this.prevBtn) {
        this.prevBtn.addEventListener('click', (e) => {
          e.preventDefault();
          this.previousSlide();
        });
      }

      if (this.nextBtn) {
        this.nextBtn.addEventListener('click', (e) => {
          e.preventDefault();
          this.nextSlide();
        });
      }

      // Add keyboard navigation
      this.addEventListener('keydown', (e) => {
        if (e.key === 'ArrowLeft') {
          e.preventDefault();
          this.previousSlide();
        } else if (e.key === 'ArrowRight') {
          e.preventDefault();
          this.nextSlide();
        }
      });

      // Make the gallery focusable for keyboard navigation
      this.setAttribute('tabindex', '0');

      // Add touch/swipe handlers
      this.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
      this.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
      this.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: true });

      // Ensure only the initial active slide can play
      this.pauseNonActiveSlides();
      this.autoplayActiveSlideIfEligible();
    }

    goToSlide(index, direction = 'next') {
      if (index < 0 || index >= this.slides.length) return;
      if (index === this.currentIndex) return;
      if (this.isAnimating) return;

      const currentSlide = this.slides[this.currentIndex];
      const targetSlide = this.slides[index];

      // Before animating, pause non-target slides
      this.pauseAllMediaExceptInSlide(targetSlide);

      // Reset all slides
      this.resetSlides();

      // Position slides for animation
      this.positionSlidesForAnimation(currentSlide, targetSlide, direction);

      // Update UI state
      this.updateThumbnails(index);
      this.currentIndex = index;

      // Start animation after positioning is applied
      this.animateSlides(currentSlide, targetSlide, direction);

      // Adjust container height for natural aspect ratio after slide change
      if (this.isNaturalAspectRatio) {
        setTimeout(() => {
          this.adjustContainerHeight();
        }, 50); // Small delay to ensure slide is positioned
      }
    }

    resetSlides() {
      this.slides.forEach(slide => {
        slide.classList.remove('media-gallery__slide--prev', 'media-gallery__slide--next');
      });
    }

    positionSlidesForAnimation(currentSlide, targetSlide, direction) {
      // Remove animation class from all slides during positioning
      this.slides.forEach(slide => {
        slide.classList.remove('media-gallery__slide--is-animating');
      });

      if (direction === 'next') {
        // Next: target and others on right, current stays in center
        targetSlide.style.transform = 'translateX(100%)';
        this.slides.forEach(slide => {
          if (slide !== currentSlide && slide !== targetSlide) {
            slide.style.transform = 'translateX(100%)';
          }
        });
      } else {
        // Previous: target and others on left, current stays in center
        targetSlide.style.transform = 'translateX(-100%)';
        this.slides.forEach(slide => {
          if (slide !== currentSlide && slide !== targetSlide) {
            slide.style.transform = 'translateX(-100%)';
          }
        });
      }
    }

    updateThumbnails(activeIndex) {
      this.thumbs[this.currentIndex].classList.remove('media-gallery__thumb--active');
      this.thumbs[activeIndex].classList.add('media-gallery__thumb--active');
    }

    adjustContainerHeight(disableTransition = false) {
      if (!this.isNaturalAspectRatio || !this.mainContainer) return;

      const activeSlide = this.slides[this.currentIndex];
      if (!activeSlide) return;

      // Disable transition if requested (for initial setup)
      if (disableTransition) {
        this.mainContainer.classList.add('media-gallery__main--no-transition');
      }

      // Find the image in the active slide
      const img = activeSlide.querySelector('img');
      if (!img) return;

      // Get the image's natural dimensions
      const imgWidth = img.naturalWidth || img.width;
      const imgHeight = img.naturalHeight || img.height;

      if (imgWidth && imgHeight) {
        // Calculate the aspect ratio
        const aspectRatio = imgWidth / imgHeight;

        // Get the container width
        const containerWidth = this.mainContainer.offsetWidth;

        // Calculate the height based on the aspect ratio
        const calculatedHeight = containerWidth / aspectRatio;

        // Set the container height
        this.mainContainer.style.height = calculatedHeight + 'px';
      } else {
        // Fallback: temporarily make the slide visible to measure its height
        const originalOpacity = activeSlide.style.opacity;
        const originalTransform = activeSlide.style.transform;
        activeSlide.style.opacity = '1';
        activeSlide.style.transform = 'translateX(0)';
        activeSlide.style.position = 'relative';
        activeSlide.style.visibility = 'hidden';

        // Get the natural height of the slide content
        const slideHeight = activeSlide.offsetHeight;

        // Restore original styles
        activeSlide.style.opacity = originalOpacity;
        activeSlide.style.transform = originalTransform;
        activeSlide.style.position = '';
        activeSlide.style.visibility = '';

        // Set the container height to match the slide height
        this.mainContainer.style.height = slideHeight + 'px';
      }

      // Re-enable transition after a brief delay
      if (disableTransition) {
        setTimeout(() => {
          this.mainContainer.classList.remove('media-gallery__main--no-transition');
        }, 10);
      }
    }

    setupResizeObserver() {
      if (!this.isNaturalAspectRatio || !window.ResizeObserver) return;

      this.resizeObserver = new ResizeObserver(() => {
        this.adjustContainerHeight();
      });

      // Observe all images in the slides
      this.slides.forEach(slide => {
        const images = slide.querySelectorAll('img');
        images.forEach(img => {
          this.resizeObserver.observe(img);

          // Also listen for load events to recalculate when image loads
          img.addEventListener('load', () => {
            if (slide.classList.contains('media-gallery__slide--active')) {
              this.adjustContainerHeight();
            }
          });
        });
      });
    }

    animateSlides(currentSlide, targetSlide, direction) {
      // Set animation flag
      this.isAnimating = true;

      // Frame 1: Initial positions are already set (no animation class)

      // Frame 2: Add animation class
      requestAnimationFrame(() => {
        currentSlide.classList.add('media-gallery__slide--is-animating');
        targetSlide.classList.add('media-gallery__slide--is-animating');

        // Frame 3: Trigger animation
        requestAnimationFrame(() => {
          // Animate current slide out
          if (direction === 'next') {
            currentSlide.style.transform = 'translateX(-100%)';
          } else {
            currentSlide.style.transform = 'translateX(100%)';
          }

          // Animate target slide in
          targetSlide.style.transform = 'translateX(0)';
          targetSlide.classList.add('media-gallery__slide--active');

          // Clean up after animation
          this.waitForTransition(currentSlide).then(() => {
            this.positionCurrentSlideAfterAnimation(currentSlide, targetSlide, direction);
            this.scrollActiveThumbnailIntoView();
            // Hydrate and autoplay current slide media if eligible and pause others
            this.pauseNonActiveSlides();
            this.autoplayActiveSlideIfEligible();
            // Clear animation flag
            this.isAnimating = false;
          });
        });
      });
    }

    positionCurrentSlideAfterAnimation(currentSlide, targetSlide, direction) {
      // Remove animation class and active class from current slide
      currentSlide.classList.remove('media-gallery__slide--is-animating', 'media-gallery__slide--active');
      targetSlide.classList.remove('media-gallery__slide--is-animating');

      if (direction === 'next') {
        currentSlide.style.transform = 'translateX(100%)';
      } else {
        currentSlide.style.transform = 'translateX(-100%)';
      }

      // Adjust container height for natural aspect ratio after animation
      if (this.isNaturalAspectRatio) {
        this.adjustContainerHeight();
      }
    }

    scrollActiveThumbnailIntoView() {
      this.thumbs[this.currentIndex].scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
        inline: 'center'
      });
    }

    nextSlide() {
      const nextIndex = (this.currentIndex + 1) % this.slides.length;
      this.goToSlide(nextIndex, 'next');
    }

    previousSlide() {
      const prevIndex = this.currentIndex === 0 ? this.slides.length - 1 : this.currentIndex - 1;
      this.goToSlide(prevIndex, 'prev');
    }

    waitForTransition(element) {
      return new Promise((resolve) => {
        const handleTransitionEnd = (e) => {
          if (e.target === element) {
            element.removeEventListener('transitionend', handleTransitionEnd);
            resolve();
          }
        };
        element.addEventListener('transitionend', handleTransitionEnd);
      });
    }

    handleTouchStart(e) {
      if (this.isAnimating) return;

      const touch = e.touches[0];
      this.touchStartX = touch.clientX;
      this.touchStartY = touch.clientY;
    }

    handleTouchMove(e) {
      if (this.isAnimating) return;

      const touch = e.touches[0];
      this.touchEndX = touch.clientX;
      this.touchEndY = touch.clientY;
    }

    handleTouchEnd(e) {
      if (this.isAnimating) return;

      const deltaX = this.touchEndX - this.touchStartX;
      const deltaY = this.touchEndY - this.touchStartY;
      const absDeltaX = Math.abs(deltaX);
      const absDeltaY = Math.abs(deltaY);

      // Only trigger swipe if horizontal movement is greater than vertical
      if (absDeltaX > absDeltaY && absDeltaX > this.minSwipeDistance) {
        if (deltaX > 0) {
          // Swipe right - go to previous slide
          this.previousSlide();
        } else {
          // Swipe left - go to next slide
          this.nextSlide();
        }
      }
    }

    // Utilities for media control
    pauseAllMediaExceptInSlide(slide) {
      const allowed = new Set(slide ? Array.from(slide.querySelectorAll('video-media, model-media')) : []);
      this.querySelectorAll('video-media[playing], model-media[playing]').forEach((el) => {
        if (!allowed.has(el)) {
          el.pause();
        }
      });
    }

    pauseNonActiveSlides() {
      this.slides.forEach((slide, index) => {
        if (index !== this.currentIndex) {
          slide.querySelectorAll('video-media[playing], model-media[playing]').forEach((element) => element.pause());
        }
      });
    }

    autoplayActiveSlideIfEligible() {
      const activeSlide = this.slides[this.currentIndex];
      if (!activeSlide) return;

      const mediaEls = activeSlide.querySelectorAll('video-media, model-media');
      mediaEls.forEach((element) => {
        if (!element.hasAttribute('autoplay')) return;
        if (element.hasAttribute('playing')) return;
        // If already loaded, try immediately; else wait for 'loaded' attribute
        if (element.hasAttribute('loaded')) {
          element.play();
        } else {
          const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
              if (mutation.type === 'attributes' && mutation.attributeName === 'loaded' && element.hasAttribute('loaded')) {
                observer.disconnect();
                element.play();
                break;
              }
            }
          });

          observer.observe(element, { attributes: true, attributeFilter: ['loaded'] });
        }
      });
    }
  }

  // Define the custom element
  customElements.define('media-gallery', MediaGalleryElement);
{% endjavascript %}