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.