PageSourceSearch

https://camillanylund.com/wp-content/plugins/portfolio-filter-gallery/public/js/pfg-gallery.js?ver=2.2.0

js camillanylund.com collected 2026-10-02 12:50:11 UTC 29,537 bytes, 961 lines download raw bytes

1/**
2 * Portfolio Filter Gallery - Core JavaScript
3 * Vanilla JS - No jQuery dependency
4 * Supports multi-filter selection and AND/OR logic
5 *
6 * @package Portfolio_Filter_Gallery
7 * @version 2.1.0
8 */
9
10(function () {
11  "use strict";
12
13  /**
14   * Gallery class for handling filtering and interactions
15   */
16  class PFGGallery {
17    constructor(container) {
18      this.container = container;
19      this.galleryId = container.dataset.galleryId;
20      this.grid = container.querySelector(".pfg-grid");
21      this.filtersContainer = container.querySelector(".pfg-filters");
22      this.filters = container.querySelectorAll(".pfg-filter");
23      this.items = container.querySelectorAll(".pfg-item");
24      this.searchInput = container.querySelector(".pfg-search-input");
25      this.logicToggle = container.querySelector(".pfg-logic-toggle");
26
27      // Multi-filter settings
28      this.multiSelect = container.dataset.multiSelect === "true";
29      this.filterLogic = container.dataset.filterLogic || "or"; // 'and' or 'or'
30      this.activeFilters = new Set();
31      this.searchTerm = "";
32
33      // Deep linking settings
34      this.deepLinking = container.dataset.deepLinking === "true";
35      this.urlParam = container.dataset.urlParam || "filter";
36      this.defaultFilter = container.dataset.defaultFilter || "";
37
38      // Filter hierarchy (parent slug => array of child slugs)
39      this.filterHierarchy = {};
40      try {
41        this.filterHierarchy = JSON.parse(
42          container.dataset.filterHierarchy || "{}"
43        );
44      } catch (e) {
45      }
46
47      this.init();
48    }
49
50    init() {
51      this.bindFilters();
52      this.bindSearch();
53      this.bindLogicToggle();
54      this.bindCascadingDropdowns();
55      this.initLazyLoading();
56      this.assignStaggerDelays();
57      this.initDeepLinking();
58      this.initMasonryLayout();
59      this.initPreloader();
60    }
61
62    /**
63     * Initialize preloader - hide after images load
64     */
65    initPreloader() {
66      // Check if all images are already loaded (cached)
67      const images = Array.from(this.container.querySelectorAll('.pfg-grid img'));
68      const allLoaded = images.every(img => img.complete && img.naturalHeight !== 0);
69      
70      if (allLoaded) {
71        // Images already loaded, remove preloader immediately
72        this.removePreloader();
73        return;
74      }
75      
76      // Wait for images to load
77      this.waitForImagesMain().then(() => {
78        this.removePreloader();
79      });
80      
81      // Fallback: always hide preloader after 3 seconds max
82      setTimeout(() => {
83        this.removePreloader();
84      }, 3000);
85    }
86
87    /**
88     * Wait for images in main grid
89     */
90    waitForImagesMain() {
91      const images = Array.from(this.container.querySelectorAll('.pfg-grid img'));
92      const promises = images.map((img) => {
93        if (img.complete) {
94          return Promise.resolve();
95        }
96        return new Promise((resolve) => {
97          img.addEventListener('load', resolve, { once: true });
98          img.addEventListener('error', resolve, { once: true });
99          // Timeout fallback
100          setTimeout(resolve, 5000);
101        });
102      });
103      return Promise.all(promises);
104    }
105
106    /**
107     * Remove preloader and show gallery
108     */
109    removePreloader() {
110      this.container.classList.remove('pfg-loading');
111      this.container.classList.add('pfg-loaded');
112    }
113
114    /**
115     * Initialize URL filter and default filter
116     * URL param ALWAYS works, Deep Linking only controls URL updates on click
117     */
118    initDeepLinking() {
119      // Check URL param first (highest priority, always works)
120      const urlParams = new URLSearchParams(window.location.search);
121      const urlFilter = urlParams.get(this.urlParam);
122
123      if (urlFilter) {
124        this.activateFilterBySlug(urlFilter);
125        return;
126      }
127
128      // Otherwise, activate default filter if set
129      if (this.defaultFilter) {
130        this.activateFilterBySlug(this.defaultFilter);
131      } else {
132        // No URL filter and no default filter - activate "All" button
133        const allBtn = this.container.querySelector('[data-filter="*"]');
134        if (allBtn) {
135          allBtn.classList.add("pfg-filter--active");
136        }
137      }
138    }
139
140    /**
141     * Activate a filter by its slug
142     */
143    activateFilterBySlug(slug) {
144      const filterBtn = this.container.querySelector(
145        `.pfg-filter[data-filter="${slug}"]`
146      );
147      if (filterBtn) {
148        this.setSingleFilter(slug, filterBtn);
149        this.filterItems();
150      }
151    }
152
153    /**
154     * Update URL with current filter (deep linking)
155     */
156    updateUrl(filter) {
157      if (!this.deepLinking) return;
158
159      const url = new URL(window.location.href);
160
161      if (filter === "*" || !filter) {
162        url.searchParams.delete(this.urlParam);
163      } else {
164        url.searchParams.set(this.urlParam, filter);
165      }
166
167      window.history.replaceState({}, "", url.toString());
168    }
169
170    /**
171     * Assign stagger delays to items for animation
172     */
173    assignStaggerDelays() {
174      this.items.forEach((item, index) => {
175        item.dataset.delay = (index % 6) + 1;
176      });
177    }
178
179    /**
180     * Bind filter button click events
181     */
182    bindFilters() {
183      this.filters.forEach((filter) => {
184        filter.addEventListener("click", (e) => {
185          e.preventDefault();
186          const filterValue = filter.dataset.filter;
187
188          if (this.multiSelect && filterValue !== "*") {
189            // Multi-select mode: toggle filter
190            this.toggleFilter(filterValue, filter);
191          } else {
192            // Single-select mode or "All" clicked
193            this.setSingleFilter(filterValue, filter);
194          }
195
196          this.filterItems();
197        });
198      });
199    }
200
201    /**
202     * Toggle a filter in multi-select mode
203     */
204    toggleFilter(filter, button) {
205      if (this.activeFilters.has(filter)) {
206        this.activeFilters.delete(filter);
207        button.classList.remove("pfg-filter--selected", "pfg-filter--active");
208      } else {
209        this.activeFilters.add(filter);
210        button.classList.add("pfg-filter--selected", "pfg-filter--active");
211      }
212
213      // Update "All" button state
214      const allBtn = this.container.querySelector('[data-filter="*"]');
215      if (allBtn) {
216        if (this.activeFilters.size === 0) {
217          allBtn.classList.add("pfg-filter--active");
218        } else {
219          allBtn.classList.remove("pfg-filter--active");
220        }
221      }
222    }
223
224    /**
225     * Set single active filter (clears others)
226     */
227    setSingleFilter(filter, button) {
228      this.activeFilters.clear();
229
230      if (filter !== "*") {
231        this.activeFilters.add(filter);
232      }
233
234      // Update button states
235      this.filters.forEach((btn) => {
236        btn.classList.remove("pfg-filter--active", "pfg-filter--selected");
237      });
238      button.classList.add("pfg-filter--active");
239
240      // Update URL for deep linking
241      this.updateUrl(filter);
242    }
243
244    /**
245     * Bind search input events
246     */
247    bindSearch() {
248      if (!this.searchInput) return;
249
250      let debounceTimer;
251      this.searchInput.addEventListener("input", (e) => {
252        clearTimeout(debounceTimer);
253        debounceTimer = setTimeout(() => {
254          this.searchTerm = e.target.value.toLowerCase().trim();
255          this.filterItems();
256        }, 300);
257      });
258    }
259
260    /**
261     * Bind AND/OR logic toggle
262     */
263    bindLogicToggle() {
264      if (!this.logicToggle) return;
265
266      const buttons = this.logicToggle.querySelectorAll(".pfg-logic-btn");
267      buttons.forEach((btn) => {
268        btn.addEventListener("click", () => {
269          this.filterLogic = btn.dataset.logic;
270          
271          // Update container dataset so AJAX picks up the current logic
272          this.container.dataset.filterLogic = this.filterLogic;
273
274          // Update button states
275          buttons.forEach((b) => b.classList.remove("pfg-logic-btn--active"));
276          btn.classList.add("pfg-logic-btn--active");
277
278          this.filterItems();
279        });
280      });
281    }
282
283    /**
284     * Bind cascading dropdown events (single-level hierarchy)
285     */
286    bindCascadingDropdowns() {
287      const dropdownContainer = this.container.querySelector('.pfg-cascading-dropdowns');
288      if (!dropdownContainer) return;
289
290      const level1Select = dropdownContainer.querySelector('.pfg-level1-select');
291      if (!level1Select) return;
292
293      // Level 1 dropdown change
294      level1Select.addEventListener('change', () => {
295        const level1Value = level1Select.value;
296
297        // Clear active filters and set new one
298        this.activeFilters.clear();
299        if (level1Value !== '*') {
300          this.activeFilters.add(level1Value);
301        }
302
303        // Update URL if deep linking enabled
304        if (this.deepLinking && level1Value !== '*') {
305          this.updateUrl(level1Value);
306        } else if (this.deepLinking) {
307          this.updateUrl('*');
308        }
309
310        this.filterItems();
311      });
312    }
313
314    /**
315     * Filter gallery items with smooth animation
316     * Uses masonry-aware repositioning for masonry layouts,
317     * FLIP animation for other layouts
318     */
319    filterItems() {
320      // Re-query items from DOM
321      this.items = this.container.querySelectorAll(".pfg-item");
322      
323      // Check if masonry layout — needs different animation strategy
324      const isMasonry = this.grid && this.grid.classList.contains("pfg-grid--masonry");
325      
326      if (isMasonry) {
327        this._filterItemsMasonry();
328      } else {
329        this._filterItemsFLIP();
330      }
331
332      // Dispatch custom event for other scripts
333      let visibleCount = 0;
334      this.items.forEach((item) => {
335        if (!item.classList.contains("pfg-item--hidden") && !item.classList.contains("pfg-item--hiding")) {
336          visibleCount++;
337        }
338      });
339      this.container.dispatchEvent(
340        new CustomEvent("pfg:filtered", {
341          bubbles: true,
342          detail: {
343            filters: Array.from(this.activeFilters),
344            logic: this.filterLogic,
345            search: this.searchTerm,
346            visibleCount: visibleCount,
347          },
348        })
349      );
350
351
352    }
353
354    /**
355     * Masonry-aware filtering: hide/show items then recalculate all positions.
356     * CSS transitions on left/top handle smooth repositioning of remaining items.
357     */
358    _filterItemsMasonry() {
359      const itemsToShow = [];
360      const itemsToHide = [];
361
362      this.items.forEach((item) => {
363        const matchesFilter = this.itemMatchesFilter(item);
364        const matchesSearch = this.itemMatchesSearch(item);
365        const wasHidden = item.classList.contains("pfg-item--hidden");
366
367        if (matchesFilter && matchesSearch) {
368          if (wasHidden) {
369            itemsToShow.push(item);
370          }
371        } else if (!wasHidden) {
372          itemsToHide.push(item);
373        }
374      });
375
376      // Phase 1: Fade out items that need hiding
377      itemsToHide.forEach((item) => {
378        item.classList.remove("pfg-item--visible", "pfg-item--hidden");
379        item.classList.add("pfg-item--hiding");
380      });
381
382      // Phase 2: After fade-out completes, collapse hidden items and recalculate layout
383      setTimeout(() => {
384        // Mark hidden items
385        itemsToHide.forEach((item) => {
386          item.classList.remove("pfg-item--hiding");
387          item.classList.add("pfg-item--hidden");
388          item.classList.remove("pfg-item--positioned");
389        });
390
391        // Prepare items to show (they'll be positioned by initMasonryLayout)
392        itemsToShow.forEach((item) => {
393          item.classList.remove("pfg-item--hidden", "pfg-item--hiding");
394          item.classList.add("pfg-item--visible");
395          item.classList.remove("pfg-item--positioned");
396        });
397
398        // Recalculate all masonry positions
399        this.initMasonryLayout();
400
401      }, 250); // Wait for hide (opacity) animation
402    }
403
404    /**
405     * FLIP-based filtering for non-masonry layouts
406     * FLIP = First, Last, Invert, Play - for smooth position animations
407     */
408    _filterItemsFLIP() {
409      // FLIP Step 1: FIRST - Record current positions of ALL items
410      const firstPositions = new Map();
411      this.items.forEach((item) => {
412        if (!item.classList.contains("pfg-item--hidden")) {
413          const rect = item.getBoundingClientRect();
414          firstPositions.set(item, { x: rect.left, y: rect.top });
415        }
416      });
417      
418      let visibleIndex = 0;
419      const itemsToShow = [];
420      const itemsToHide = [];
421      const itemsToAnimate = [];
422
423      this.items.forEach((item) => {
424        const matchesFilter = this.itemMatchesFilter(item);
425        const matchesSearch = this.itemMatchesSearch(item);
426        const wasVisible = !item.classList.contains("pfg-item--hidden");
427
428        if (matchesFilter && matchesSearch) {
429          if (wasVisible) {
430            itemsToAnimate.push(item);
431          } else {
432            itemsToShow.push({ item, index: visibleIndex });
433          }
434          visibleIndex++;
435        } else if (wasVisible) {
436          itemsToHide.push(item);
437        }
438      });
439
440      itemsToHide.forEach((item) => {
441        item.classList.remove("pfg-item--visible", "pfg-item--hidden");
442        item.classList.add("pfg-item--hiding");
443      });
444
445      setTimeout(() => {
446        itemsToHide.forEach((item) => {
447          item.classList.remove("pfg-item--hiding");
448          item.classList.add("pfg-item--hidden");
449        });
450
451        itemsToShow.forEach(({ item }) => {
452          item.classList.remove("pfg-item--hidden", "pfg-item--hiding");
453          item.style.opacity = "0";
454          item.style.transform = "scale(0.9)";
455          item.style.transition = "none";
456        });
457
458        void this.container.offsetHeight;
459
460        const lastPositions = new Map();
461        [...itemsToAnimate, ...itemsToShow.map(i => i.item)].forEach((item) => {
462          const rect = item.getBoundingClientRect();
463          lastPositions.set(item, { x: rect.left, y: rect.top });
464        });
465
466        itemsToAnimate.forEach((item) => {
467          const first = firstPositions.get(item);
468          const last = lastPositions.get(item);
469          item.style.opacity = "1";
470          
471          if (first && last) {
472            const deltaX = first.x - last.x;
473            const deltaY = first.y - last.y;
474            const didMove = Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1;
475            
476            item.style.transition = "none";
477            if (didMove) {
478              item.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(0.97)`;
479            } else {
480              item.style.transform = "scale(0.97)";
481            }
482            void item.offsetHeight;
483            item.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
484            item.style.transform = "translate(0, 0) scale(1)";
485          } else {
486            item.style.transition = "none";
487            item.style.transform = "scale(0.97)";
488            void item.offsetHeight;
489            item.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
490            item.style.transform = "scale(1)";
491          }
492        });
493
494        itemsToShow.forEach(({ item, index }) => {
495          const delay = index * 50;
496          setTimeout(() => {
497            item.style.transition = "opacity 0.3s ease-out, transform 0.3s ease-out";
498            item.style.opacity = "1";
499            item.style.transform = "scale(1)";
500          }, delay);
501        });
502
503        setTimeout(() => {
504          itemsToAnimate.forEach((item) => {
505            item.style.transition = "";
506            item.style.transform = "";
507            item.style.opacity = "";
508          });
509          itemsToShow.forEach(({ item }) => {
510            item.style.transition = "";
511            item.style.transform = "";
512            item.style.opacity = "";
513            item.classList.add("pfg-item--visible");
514          });
515        }, 500);
516
517      }, 280);
518    }
519
520    /**
521     * Apply mosaic layout with FLIP animation for smooth repositioning
522     */
523    applyMosaicLayoutWithFLIP(firstPositions) {
524      // Apply new layout (this sets new --pfg-x and --pfg-y values)
525      this.initMasonryLayout();
526      
527      // FLIP Step 2 & 3: After layout applied, calculate inverse transforms
528      // The CSS will handle the smooth animation via transition
529    }
530
531    /**
532     * Check if item matches current filter(s)
533     */
534    itemMatchesFilter(item) {
535      // No filters active = show all
536      if (this.activeFilters.size === 0) return true;
537
538      const itemFilters = this.getItemFilters(item);
539
540      // Expand active filters to include their children
541      const expandedFilters = this.expandFiltersWithChildren([
542        ...this.activeFilters,
543      ]);
544
545      if (this.filterLogic === "and") {
546        // AND logic: item must match ALL active filters (or their children)
547        const result = [...this.activeFilters].every((filter) => {
548          const filterAndChildren = [
549            filter,
550            ...(this.filterHierarchy[filter] || []),
551          ];
552          const matches = filterAndChildren.some((f) => itemFilters.includes(f));
553          return matches;
554        });
555        return result;
556      } else {
557        // OR logic: item must match ANY active filter (or their children)
558        return expandedFilters.some((filter) => itemFilters.includes(filter));
559      }
560    }
561
562    /**
563     * Expand filter list to include all child filters
564     */
565    expandFiltersWithChildren(filters) {
566      const expanded = new Set(filters);
567      filters.forEach((filter) => {
568        if (this.filterHierarchy[filter]) {
569          this.filterHierarchy[filter].forEach((child) => expanded.add(child));
570        }
571      });
572      return [...expanded];
573    }
574
575    /**
576     * Get all filter slugs for an item
577     */
578    getItemFilters(item) {
579      const classes = Array.from(item.classList);
580      return classes
581        .filter((c) => c.startsWith("pfg-filter-"))
582        .map((c) => c.replace("pfg-filter-", ""));
583    }
584
585    /**
586     * Check if item matches search term
587     */
588    itemMatchesSearch(item) {
589      if (!this.searchTerm) return true;
590
591      const title = item.querySelector(".pfg-item-title");
592      const alt = item.querySelector(".pfg-item-image")?.alt || "";
593
594      const searchableText = (title?.textContent || "") + " " + alt;
595      return searchableText.toLowerCase().includes(this.searchTerm);
596    }
597
598    /**
599     * Show an item with smooth animation
600     */
601    showItem(item, index) {
602      // Remove hidden class
603      item.classList.remove("pfg-item--hidden");
604
605      // Remove any previous visible class and re-add for animation
606      item.classList.remove("pfg-item--visible");
607
608      // Force reflow to restart animation
609      void item.offsetWidth;
610
611      // Add visible class with stagger delay for smooth sequential animation
612      const delay = (index % 8) * 50; // 50ms stagger between items
613      setTimeout(() => {
614        item.classList.add("pfg-item--visible");
615      }, delay);
616
617      // Reset inline styles
618      item.style.maxHeight = "";
619      item.style.margin = "";
620      item.style.padding = "";
621    }
622
623    /**
624     * Hide an item with smooth animation
625     */
626    hideItem(item) {
627      // Just add hidden class - CSS handles animation
628      item.classList.add("pfg-item--hidden");
629    }
630
631    /**
632     * Initialize lazy loading for images
633     */
634    initLazyLoading() {
635      if ("loading" in HTMLImageElement.prototype) {
636        // Native lazy loading supported
637        return;
638      }
639
640      // Fallback for browsers without native lazy loading
641      if ("IntersectionObserver" in window) {
642        const imageObserver = new IntersectionObserver(
643          (entries, observer) => {
644            entries.forEach((entry) => {
645              if (entry.isIntersecting) {
646                const img = entry.target;
647                if (img.dataset.src) {
648                  img.src = img.dataset.src;
649                  img.removeAttribute("data-src");
650                }
651                observer.unobserve(img);
652              }
653            });
654          },
655          {
656            rootMargin: "50px 0px",
657          }
658        );
659
660        this.items.forEach((item) => {
661          const img = item.querySelector("img[data-src]");
662          if (img) {
663            imageObserver.observe(img);
664          }
665        });
666      }
667    }
668
669    /**
670     * Initialize JS-positioned masonry layout
671     * Masonry: horizontal fill, shortest-column-first (Pinterest-style)
672     */
673    initMasonryLayout() {
674      const masonryGrid = this.grid?.classList.contains("pfg-grid--masonry");
675
676      if (!masonryGrid) {
677        return;
678      }
679
680      // Mark that this gallery uses masonry layout
681      this.usesMosaicLayout = true;
682
683      // Wait for all images to load before calculating layout
684      this.waitForImages().then(() => {
685        this.applyMasonryLayout();
686      });
687
688      // Re-apply on resize with debounce
689      let resizeTimeout;
690      window.addEventListener("resize", () => {
691        clearTimeout(resizeTimeout);
692        resizeTimeout = setTimeout(() => {
693          if (this.usesMosaicLayout) {
694            this.applyMasonryLayout();
695          }
696        }, 200);
697      });
698    }
699
700    /**
701     * Wait for all images to load
702     */
703    waitForImages() {
704      const images = Array.from(this.grid.querySelectorAll("img"));
705      const promises = images.map((img) => {
706        if (img.complete) {
707          return Promise.resolve();
708        }
709        return new Promise((resolve) => {
710          img.addEventListener("load", resolve, { once: true });
711          img.addEventListener("error", resolve, { once: true });
712        });
713      });
714      return Promise.all(promises);
715    }
716
717    /**
718     * Apply masonry layout with absolute positioning
719     * Places items horizontally (shortest-column-first) for left-to-right ordering
720     */
721    applyMasonryLayout() {
722      if (!this.grid) return;
723
724      const items = Array.from(
725        this.grid.querySelectorAll(".pfg-item:not(.pfg-item--hidden)")
726      );
727      if (items.length === 0) {
728        this.grid.style.height = "0px";
729        return;
730      }
731
732      const containerWidth = this.grid.offsetWidth;
733      const gap =
734        parseInt(getComputedStyle(this.grid).getPropertyValue("--pfg-gap")) ||
735        10;
736
737      const w = window.innerWidth;
738      const styles = getComputedStyle(this.grid);
739      let cols;
740      if (w >= 1200) {
741        cols = parseInt(styles.getPropertyValue("--pfg-cols-xl")) || 4;
742      } else if (w >= 992) {
743        cols = parseInt(styles.getPropertyValue("--pfg-cols-lg")) || 3;
744      } else if (w >= 768) {
745        cols = parseInt(styles.getPropertyValue("--pfg-cols-md")) || 2;
746      } else {
747        cols = parseInt(styles.getPropertyValue("--pfg-cols-sm")) || 1;
748      }
749      
750      const colWidth = (containerWidth - gap * (cols - 1)) / cols;
751
752      const newItems = [];
753      const itemData = [];
754
755      items.forEach((item) => {
756        const img = item.querySelector("img");
757        const imgLink = item.querySelector(".pfg-item-link");
758
759        const isNewItem = !item.classList.contains("pfg-item--positioned");
760
761        if (isNewItem) {
762          item.style.transition = "none";
763          item.style.opacity = "0";
764          item.style.transform = "scale(0.92)";
765        }
766
767        item.style.position = "absolute";
768        item.style.width = colWidth + "px";
769
770        if (imgLink) {
771          imgLink.style.display = "block";
772          imgLink.style.width = "100%";
773          imgLink.style.height = "auto";
774          imgLink.style.overflow = "hidden";
775        }
776        if (img) {
777          img.style.width = "100%";
778          img.style.height = "auto";
779          img.style.objectFit = "";
780        }
781
782        if (isNewItem) {
783          newItems.push(item);
784        }
785
786        itemData.push({
787          item,
788          colWidth,
789        });
790      });
791
792      setTimeout(() => {
793        const colHeights = new Array(cols).fill(0);
794
795        itemData.forEach((data) => {
796          const { item, colWidth: itemWidth } = data;
797
798          if (item.classList.contains("pfg-item--hidden")) {
799            return;
800          }
801
802          let itemHeight = Math.ceil(item.offsetHeight);
803          if (itemHeight <= 0) {
804            const img = item.querySelector("img");
805            if (img && img.naturalWidth && img.naturalHeight) {
806              itemHeight = Math.ceil(itemWidth / (img.naturalWidth / img.naturalHeight));
807            } else {
808              itemHeight = Math.ceil(itemWidth);
809            }
810          }
811
812          // Shortest-column-first placement
813          let bestCol = 0;
814          for (let c = 1; c < cols; c++) {
815            if (colHeights[c] < colHeights[bestCol]) {
816              bestCol = c;
817            }
818          }
819
820          const x = Math.round(bestCol * (colWidth + gap));
821          const y = Math.round(colHeights[bestCol]);
822
823          item.style.left = x + "px";
824          item.style.top = y + "px";
825
826          if (!item.classList.contains("pfg-item--positioned")) {
827            void item.offsetHeight;
828            item.classList.add("pfg-item--positioned");
829          }
830
831          colHeights[bestCol] = y + itemHeight + gap;
832        });
833
834        const maxHeight = Math.max(...colHeights, 0);
835        this.grid.style.position = "relative";
836        this.grid.style.height = Math.ceil(maxHeight) + "px";
837
838        if (newItems.length > 0) {
839          void this.grid.offsetHeight;
840
841          newItems.forEach((item, i) => {
842            const delay = i * 40;
843            setTimeout(() => {
844              item.style.transition = "opacity 0.35s ease-out, transform 0.35s ease-out";
845              item.style.opacity = "1";
846              item.style.transform = "scale(1)";
847
848              setTimeout(() => {
849                item.style.transition = "";
850                item.style.opacity = "";
851                item.style.transform = "";
852              }, 400);
853            }, delay);
854          });
855        }
856      }, 50);
857    }
858
859    /**
860     * Public method to re-apply mosaic layout
861     */
862    refreshMosaicLayout() {
863      if (this.usesMosaicLayout) {
864        // Update items reference
865        this.items = this.container.querySelectorAll(".pfg-item");
866
867        this.waitForImages().then(() => {
868          this.initMasonryLayout();
869        });
870      }
871    }
872
873    /**
874     * Public API: Set filter logic programmatically
875     */
876    setLogic(logic) {
877      if (logic === "and" || logic === "or") {
878        this.filterLogic = logic;
879        this.filterItems();
880      }
881    }
882
883    /**
884     * Public API: Add a filter programmatically
885     */
886    addFilter(filter) {
887      this.activeFilters.add(filter);
888      this.filterItems();
889    }
890
891    /**
892     * Public API: Clear all filters
893     */
894    clearFilters() {
895      this.activeFilters.clear();
896      this.filters.forEach((btn) => {
897        btn.classList.remove("pfg-filter--active", "pfg-filter--selected");
898      });
899      const allBtn = this.container.querySelector('[data-filter="*"]');
900      if (allBtn) allBtn.classList.add("pfg-filter--active");
901      this.filterItems();
902    }
903
904
905  }
906
907
908
909  /**
910   * Initialize galleries when DOM is ready
911   */
912  function initGalleries() {
913    const galleries = document.querySelectorAll(".pfg-gallery-wrapper");
914
915    galleries.forEach((container) => {
916      // Skip if already initialized
917      if (container.dataset.pfgInitialized) return;
918
919      container.pfgGallery = new PFGGallery(container);
920      container.dataset.pfgInitialized = "true";
921    });
922  }
923
924  // Initialize on DOM ready
925  if (document.readyState === "loading") {
926    document.addEventListener("DOMContentLoaded", initGalleries);
927  } else {
928    initGalleries();
929  }
930
931  // Re-initialize for dynamically added content
932  if (typeof MutationObserver !== "undefined") {
933    const observer = new MutationObserver((mutations) => {
934      mutations.forEach((mutation) => {
935        mutation.addedNodes.forEach((node) => {
936          if (node.nodeType === 1) {
937            if (node.classList?.contains("pfg-gallery-wrapper")) {
938              node.pfgGallery = new PFGGallery(node);
939            } else {
940              const galleries = node.querySelectorAll?.(
941                ".pfg-gallery-wrapper:not([data-pfg-initialized])"
942              );
943              galleries?.forEach((g) => {
944                g.pfgGallery = new PFGGallery(g);
945              });
946            }
947          }
948        });
949      });
950    });
951
952    observer.observe(document.body, {
953      childList: true,
954      subtree: true,
955    });
956  }
957
958  // Expose to global scope for external use
959  window.PFGGallery = PFGGallery;
960  window.pfgInitGalleries = initGalleries;
961})();

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.