PageSourceSearch

https://cdn.simplystamps.com/skin/frontend/zephyr/default/js/components/product-media.js?v=1114

js simplystamps.com collected 2026-09-24 20:30:15 UTC 13,319 bytes, 351 lines download raw bytes

1/**
2 * Product Media Gallery Alpine.js Component
3 *
4 * Manages product image gallery with:
5 * - Main image carousel with thumbnails
6 * - Lightbox/modal for full-size viewing
7 * - Keyboard navigation
8 * - Responsive mobile/desktop layouts
9 *
10 * @returns {Object} Alpine component definition
11 */
12function productMedia() {
13    return {
14        // State
15        currentIndex: 0,
16        showLightbox: false,
17        swiperReady: false,
18        // Tracks the first (eager) gallery image's own load state, independent of
19        // Swiper init, so the skeleton clears as soon as the image paints rather
20        // than waiting on deferred Alpine/Swiper bootstrapping.
21        heroLoaded: false,
22        totalImages: 0,
23        mainSwiper: null,
24        lightboxSwiper: null,
25        thumbnailSwiper: null,
26        optionRelationMap: {},
27
28        /**
29         * Initialize component
30         */
31        init() {
32            // Count slides only from main swiper (before Swiper creates loop duplicates)
33            this.totalImages = this.$refs.mainSwiper ? this.$refs.mainSwiper.querySelectorAll('.swiper-slide').length : 0;
34
35            // The hero image is eager-loaded and often finishes (or is served from
36            // cache) before Alpine attaches its @load listener. Catch that case here
37            // so the skeleton doesn't get stuck covering an already-painted image.
38            if (this.$refs.heroImage && this.$refs.heroImage.complete && this.$refs.heroImage.naturalWidth > 0) {
39                this.heroLoaded = true;
40            }
41
42            this.buildOptionRelationMap();
43
44            this.$nextTick(() => {
45                this.initializeSwipers();
46                this.setupKeyboardNav();
47                this.setupOptionRelationListener();
48                // Mark swiper as ready to hide skeleton
49                this.swiperReady = true;
50            });
51        },
52
53        /**
54         * Initialize all Swiper instances
55         */
56        initializeSwipers() {
57            // Main image swiper (desktop and mobile)
58            if (this.$refs.mainSwiper) {
59                const mainSwiperEl = this.$refs.mainSwiper;
60                const prevBtn = mainSwiperEl.querySelector('.main-swiper-button-prev');
61                const nextBtn = mainSwiperEl.querySelector('.main-swiper-button-next');
62                const paginationEl = mainSwiperEl.closest('.main-image-container').querySelector('.main-swiper-pagination');
63                const slideCount = mainSwiperEl.querySelectorAll('.swiper-slide').length;
64
65                this.mainSwiper = new Swiper(mainSwiperEl, {
66                    slidesPerView: 1,
67                    spaceBetween: 0,
68                    loop: slideCount > 1,
69                    preloadImages: true,
70                    updateOnImagesReady: true,
71                    navigation: {
72                        nextEl: nextBtn,
73                        prevEl: prevBtn,
74                    },
75                    pagination: {
76                        el: paginationEl,
77                        clickable: true,
78                        dynamicBullets: true,
79                    },
80                    on: {
81                        slideChange: (swiper) => {
82                            this.currentIndex = swiper.realIndex;
83                            // Sync thumbnail swiper to keep active thumbnail visible
84                            this.scrollThumbnailIntoView(swiper.realIndex);
85                            // Sync lightbox if open (check index to prevent infinite loop)
86                            if (this.showLightbox && this.lightboxSwiper && this.lightboxSwiper.realIndex !== swiper.realIndex) {
87                                if (this.lightboxSwiper.params.loop) {
88                                    this.lightboxSwiper.slideToLoop(swiper.realIndex);
89                                } else {
90                                    this.lightboxSwiper.slideTo(swiper.realIndex);
91                                }
92                            }
93                        }
94                    }
95                });
96            }
97
98            // Thumbnail swiper (desktop only)
99            if (this.$refs.thumbSwiper && this.totalImages > 1) {
100                this.thumbnailSwiper = new Swiper(this.$refs.thumbSwiper, {
101                    slidesPerView: 5,
102                    spaceBetween: 15,
103                    watchSlidesProgress: true,
104                    centeredSlides: false,
105                    slideToClickedSlide: true,
106                    breakpoints: {
107                        1024: {
108                            spaceBetween: 20,
109                        }
110                    }
111                });
112            }
113
114            // Note: Lightbox swiper is initialized lazily in initLightboxSwiper()
115            // to ensure it's visible when Swiper calculates dimensions for loop mode
116        },
117
118        /**
119         * Initialize lightbox swiper (called lazily when lightbox first opens)
120         * Must be called when lightbox is visible for loop mode to work correctly
121         * @param {number} initialIndex - The slide index to start at
122         */
123        initLightboxSwiper(initialIndex = 0) {
124            if (this.lightboxSwiper || !this.$refs.lightboxSwiper) return;
125
126            const lightboxSwiperEl = this.$refs.lightboxSwiper;
127            const lightboxPrevBtn = lightboxSwiperEl.querySelector('.lightbox-swiper-button-prev');
128            const lightboxNextBtn = lightboxSwiperEl.querySelector('.lightbox-swiper-button-next');
129            const lightboxPaginationEl = lightboxSwiperEl.querySelector('.lightbox-swiper-pagination');
130
131            this.lightboxSwiper = new Swiper(lightboxSwiperEl, {
132                slidesPerView: 1,
133                spaceBetween: 30,
134                loop: this.totalImages > 1,
135                navigation: {
136                    nextEl: lightboxNextBtn,
137                    prevEl: lightboxPrevBtn,
138                },
139                pagination: {
140                    el: lightboxPaginationEl,
141                    clickable: true,
142                    type: 'fraction',
143                },
144                keyboard: {
145                    enabled: true,
146                },
147                on: {
148                    slideChange: (swiper) => {
149                        this.currentIndex = swiper.realIndex;
150                        // Sync main swiper (check index to prevent infinite loop)
151                        if (this.mainSwiper && this.mainSwiper.realIndex !== swiper.realIndex) {
152                            if (this.mainSwiper.params.loop) {
153                                this.mainSwiper.slideToLoop(swiper.realIndex);
154                            } else {
155                                this.mainSwiper.slideTo(swiper.realIndex);
156                            }
157                        }
158                        // Sync thumbnail swiper
159                        this.scrollThumbnailIntoView(swiper.realIndex);
160                    }
161                }
162            });
163
164            // Navigate to the correct slide after initialization (works better with loop mode)
165            if (initialIndex > 0) {
166                this.lightboxSwiper.slideToLoop(initialIndex, 0);
167            }
168        },
169
170        /**
171         * Go to specific slide
172         * @param {number} index - Slide index to navigate to
173         */
174        goToSlide(index) {
175            this.currentIndex = index;
176            if (this.mainSwiper) {
177                // Use slideToLoop for loop-enabled swipers to handle index correctly
178                if (this.mainSwiper.params.loop) {
179                    this.mainSwiper.slideToLoop(index);
180                } else {
181                    this.mainSwiper.slideTo(index);
182                }
183            }
184            if (this.showLightbox && this.lightboxSwiper) {
185                if (this.lightboxSwiper.params.loop) {
186                    this.lightboxSwiper.slideToLoop(index);
187                } else {
188                    this.lightboxSwiper.slideTo(index);
189                }
190            }
191        },
192
193        /**
194         * Open lightbox at current or specific index
195         * @param {number|null} index - Optional index to open at
196         */
197        openLightbox(index = null) {
198            if (index !== null) {
199                this.currentIndex = index;
200            }
201
202            this.showLightbox = true;
203            document.body.style.overflow = 'hidden';
204
205            // Hide sticky header via global UI store
206            if (window.Alpine && Alpine.store('ui')) {
207                Alpine.store('ui').enterFullscreen();
208            }
209
210            // Initialize or navigate lightbox swiper
211            this.$nextTick(() => {
212                if (this.lightboxSwiper) {
213                    // Swiper already exists, just navigate to current slide
214                    if (this.lightboxSwiper.params.loop) {
215                        this.lightboxSwiper.slideToLoop(this.currentIndex, 0);
216                    } else {
217                        this.lightboxSwiper.slideTo(this.currentIndex, 0);
218                    }
219                } else {
220                    // First time opening - initialize with current index
221                    this.initLightboxSwiper(this.currentIndex);
222                }
223            });
224        },
225
226        /**
227         * Close lightbox
228         */
229        closeLightbox() {
230            this.showLightbox = false;
231            document.body.style.overflow = '';
232
233            // Show sticky header again
234            if (window.Alpine && Alpine.store('ui')) {
235                Alpine.store('ui').exitFullscreen();
236            }
237        },
238
239        /**
240         * Setup keyboard navigation
241         */
242        setupKeyboardNav() {
243            document.addEventListener('keydown', (e) => {
244                if (this.showLightbox) {
245                    if (e.key === 'Escape') {
246                        this.closeLightbox();
247                    }
248                    // Arrow keys are handled by Swiper's keyboard option
249                }
250            });
251        },
252
253        /**
254         * Handle thumbnail click
255         * @param {number} index - Thumbnail index clicked
256         */
257        selectThumbnail(index) {
258            this.goToSlide(index);
259        },
260
261        /**
262         * Handle thumbnail hover (desktop)
263         * @param {number} index - Thumbnail index hovered
264         */
265        hoverThumbnail(index) {
266            // Only on desktop
267            if (window.innerWidth >= 768) {
268                this.goToSlide(index);
269            }
270        },
271
272        /**
273         * Scroll thumbnail swiper to ensure active thumbnail is visible
274         * @param {number} index - Active thumbnail index
275         */
276        scrollThumbnailIntoView(index) {
277            if (!this.thumbnailSwiper) return;
278
279            const slidesPerView = this.thumbnailSwiper.params.slidesPerView;
280            const totalSlides = this.thumbnailSwiper.slides.length;
281
282            // Calculate the ideal position to center the active thumbnail
283            // or at least ensure it's visible
284            let targetIndex = index;
285
286            // Try to center the thumbnail, but respect bounds
287            const halfVisible = Math.floor(slidesPerView / 2);
288            targetIndex = Math.max(0, index - halfVisible);
289
290            // Don't scroll past the end
291            const maxIndex = Math.max(0, totalSlides - slidesPerView);
292            targetIndex = Math.min(targetIndex, maxIndex);
293
294            this.thumbnailSwiper.slideTo(targetIndex);
295        },
296
297        /**
298         * Get image counter text
299         * @returns {string} e.g., "1 / 5"
300         */
301        get imageCounter() {
302            return `${this.currentIndex + 1} / ${this.totalImages}`;
303        },
304
305        /**
306         * Build a map of option title -> slide index from data-option-relation attributes
307         */
308        buildOptionRelationMap() {
309            this.optionRelationMap = {};
310            if (!this.$refs.mainSwiper) return;
311
312            var slides = this.$refs.mainSwiper.querySelectorAll('.swiper-slide');
313            slides.forEach(function(slide, index) {
314                var relation = slide.getAttribute('data-option-relation');
315                if (relation) {
316                    this.optionRelationMap[relation.toLowerCase()] = index;
317                }
318            }.bind(this));
319        },
320
321        /**
322         * Listen for option-focus events dispatched by custom option selects
323         */
324        setupOptionRelationListener() {
325            if (Object.keys(this.optionRelationMap).length === 0) return;
326
327            document.addEventListener('option-focus', function(e) {
328                var title = e.detail && e.detail.title;
329                if (!title) return;
330
331                var index = this.optionRelationMap[title.toLowerCase()];
332                if (index !== undefined) {
333                    this.goToSlide(index);
334                }
335            }.bind(this));
336        },
337
338        /**
339         * Cleanup when component is destroyed
340         */
341        destroy() {
342            if (this.mainSwiper) this.mainSwiper.destroy();
343            if (this.thumbnailSwiper) this.thumbnailSwiper.destroy();
344            if (this.lightboxSwiper) this.lightboxSwiper.destroy();
345            document.body.style.overflow = '';
346        }
347    };
348}
349
350// Make globally available for Alpine
351window.productMedia = productMedia;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.