PageSourceSearch

https://railsblocks.com/assets/controllers/lightbox_controller-70a…b68e8c49441b936e3f9d85de07c12ed4cd569ff5.js

js railsblocks.com collected 2026-09-25 20:23:35 UTC 21,273 bytes, 645 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import PhotoSwipe from "photoswipe";
3
4export default class extends Controller {
5  static targets = ["trigger", "gallery"];
6  static values = {
7    options: Object,
8    gallerySelector: String,
9    showDownloadButton: { type: Boolean, default: true },
10    showZoomIndicator: { type: Boolean, default: true },
11    showDotsIndicator: { type: Boolean, default: true },
12  };
13
14  connect() {
15    this.photoSwipeHostDialog = null;
16    this.loadCSS();
17    this.setupGallery();
18  }
19
20  loadCSS() {
21    // Check if PhotoSwipe CSS is already loaded
22    if (!document.querySelector('link[href*="photoswipe.css"]')) {
23      const link = document.createElement("link");
24      link.rel = "stylesheet";
25      link.href = "https://cdn.jsdelivr.net/npm/[email protected]/dist/photoswipe.css";
26      document.head.appendChild(link);
27    }
28  }
29
30  setupGallery() {
31    if (this.hasGalleryTarget) {
32      this.galleryTarget.addEventListener("click", this.handleGalleryClick.bind(this));
33    } else if (this.hasTriggerTarget) {
34      this.triggerTargets.forEach((trigger) => {
35        trigger.addEventListener("click", this.handleSingleClick.bind(this));
36      });
37    }
38  }
39
40  handleGalleryClick(e) {
41    const clickedElement = e.target.closest("a[data-pswp-src]");
42    if (!clickedElement) return;
43
44    e.preventDefault();
45
46    const galleryElements = this.galleryTarget.querySelectorAll("a[data-pswp-src]");
47    const items = Array.from(galleryElements).map((el) => this.getItemData(el));
48    const clickedIndex = Array.from(galleryElements).indexOf(clickedElement);
49
50    this.openPhotoSwipe(items, clickedIndex);
51  }
52
53  handleSingleClick(e) {
54    e.preventDefault();
55    const clickedElement = e.currentTarget;
56    const items = [this.getItemData(clickedElement)];
57    this.openPhotoSwipe(items, 0);
58  }
59
60  getItemData(element) {
61    const item = {
62      src: element.dataset.pswpSrc || element.href,
63      width: parseInt(element.dataset.pswpWidth) || 0,
64      height: parseInt(element.dataset.pswpHeight) || 0,
65      alt: element.dataset.pswpAlt || "",
66    };
67
68    // Add caption if exists
69    const caption = element.dataset.pswpCaption;
70    if (caption) {
71      item.caption = caption;
72    }
73
74    // If dimensions not provided, we'll need to load them
75    if (!item.width || !item.height) {
76      // Try to get dimensions from the thumbnail if it's already loaded
77      const thumbnail = element.querySelector("img");
78      if (thumbnail && thumbnail.complete && thumbnail.naturalWidth) {
79        // Calculate aspect ratio from thumbnail
80        const aspectRatio = thumbnail.naturalWidth / thumbnail.naturalHeight;
81
82        // Determine a reasonable full-size dimension based on aspect ratio
83        // This provides better defaults for different image types
84        if (aspectRatio > 1.2) {
85          // Landscape image
86          item.width = 2400;
87          item.height = Math.round(2400 / aspectRatio);
88        } else if (aspectRatio < 0.8) {
89          // Portrait image
90          item.height = 2400;
91          item.width = Math.round(2400 * aspectRatio);
92        } else {
93          // Square or nearly square
94          item.width = 2000;
95          item.height = 2000;
96        }
97      } else {
98        // Conservative fallback when we have no information
99        // Use a moderate size that works for most cases
100        item.width = 1920;
101        item.height = 1080; // 16:9 as a safe default
102      }
103      item.needsUpdate = true;
104    }
105
106    return item;
107  }
108
109  openPhotoSwipe(items, index) {
110    const { appendTarget, hostDialog } = this.getAppendTarget();
111
112    const options = {
113      index: index || 0,
114      appendToEl: appendTarget,
115      ...this.defaultOptions,
116      ...this.optionsValue,
117    };
118
119    const pswp = new PhotoSwipe({
120      dataSource: items,
121      ...options,
122    });
123
124    let hostCancelHandler;
125    const cleanupHostDialog = (() => {
126      let cleanedUp = false;
127      return () => {
128        if (cleanedUp || !hostDialog) return;
129        cleanedUp = true;
130
131        if (hostCancelHandler) {
132          hostDialog.removeEventListener("cancel", hostCancelHandler);
133        }
134
135        this.teardownPhotoSwipeHost(hostDialog);
136      };
137    })();
138
139    if (hostDialog) {
140      hostCancelHandler = (event) => {
141        // Keep dialog state in sync when Escape is pressed on the host.
142        event.preventDefault();
143        pswp.close();
144      };
145      hostDialog.addEventListener("cancel", hostCancelHandler);
146      pswp.on("destroy", cleanupHostDialog);
147    }
148
149    // Add dynamic cursor handling
150    const updateCursor = () => {
151      if (!pswp.currSlide || !pswp.currSlide.container) return;
152
153      const currZoom = pswp.currSlide.currZoomLevel;
154      const container = pswp.currSlide.container;
155      const imageEl = container.querySelector(".pswp__img");
156
157      // Set default cursor on container (the whole area)
158      container.style.cursor = "default";
159
160      // Only set zoom cursor on the actual image
161      if (imageEl) {
162        if (pswp.currSlide.isZoomable()) {
163          if (currZoom < 0.95) {
164            // Below 100%, will zoom to actual size
165            imageEl.style.cursor = "zoom-in";
166            imageEl.style.setProperty("cursor", "zoom-in", "important");
167          } else if (currZoom < 1.5) {
168            // At 100%, will zoom to 1.5x
169            imageEl.style.cursor = "zoom-in";
170            imageEl.style.setProperty("cursor", "zoom-in", "important");
171          } else if (currZoom < 2) {
172            // At 1.5x, will zoom to 2x
173            imageEl.style.cursor = "zoom-in";
174            imageEl.style.setProperty("cursor", "zoom-in", "important");
175          } else {
176            // At 2x or higher, will reset to fit
177            imageEl.style.cursor = "zoom-out";
178            imageEl.style.setProperty("cursor", "zoom-out", "important");
179          }
180        } else {
181          // For non-zoomable images, use default cursor
182          imageEl.style.cursor = "default";
183          imageEl.style.setProperty("cursor", "default", "important");
184        }
185      }
186    };
187
188    // Override PhotoSwipe's default cursor behavior
189    pswp.on("firstUpdate", () => {
190      // Remove PhotoSwipe's default cursor handling
191      const styleId = "pswp-custom-cursor-override";
192      if (!document.getElementById(styleId)) {
193        const style = document.createElement("style");
194        style.id = styleId;
195        style.textContent = `
196          /* Override PhotoSwipe's default cursor styles */
197          .pswp__img {
198            /* Cursor will be set by JavaScript */
199          }
200          .pswp__img--placeholder {
201            cursor: default !important;
202          }
203          .pswp__container {
204            cursor: default !important;
205          }
206          .pswp__container.pswp__container--dragging {
207            cursor: grabbing !important;
208          }
209          .pswp__container.pswp__container--dragging .pswp__img {
210            cursor: grabbing !important;
211          }
212          /* Prevent PhotoSwipe from setting zoom-out cursor */
213          .pswp--zoom-allowed .pswp__img {
214            /* Cursor controlled by JavaScript */
215          }
216          .pswp__zoom-wrap {
217            cursor: inherit !important;
218          }
219        `;
220        document.head.appendChild(style);
221      }
222    });
223
224    // Update cursor on various events
225    pswp.on("zoomPanUpdate", () => {
226      setTimeout(updateCursor, 0); // Defer to ensure DOM is updated
227    });
228    pswp.on("change", updateCursor);
229    pswp.on("afterInit", updateCursor);
230
231    // Update cursor after zoom animation completes
232    pswp.on("slideDestroy", updateCursor);
233    pswp.on("contentActivate", updateCursor);
234
235    // Continuously enforce cursor to prevent PhotoSwipe from overriding
236    let cursorInterval;
237    pswp.on("openingAnimationStart", () => {
238      // Start enforcing cursor when lightbox opens
239      cursorInterval = setInterval(updateCursor, 50);
240    });
241
242    pswp.on("close", () => {
243      // Stop enforcing cursor when lightbox closes
244      if (cursorInterval) {
245        clearInterval(cursorInterval);
246      }
247      cleanupHostDialog();
248    });
249
250    // Handle single clicks to zoom instead of close
251    pswp.on("imageClickAction", (e) => {
252      // Prevent default action (which might close the lightbox)
253      e.preventDefault();
254
255      const currZoom = pswp.currSlide.currZoomLevel;
256      let newZoom;
257
258      // Cycle through zoom levels
259      if (currZoom < 0.95) {
260        // If below 100%, first zoom to 100% (actual size)
261        newZoom = 1;
262      } else if (currZoom < 1.5) {
263        // From 100%, zoom to 1.5x
264        newZoom = 1.5;
265      } else if (currZoom < 2) {
266        // From 1.5x, zoom to 2x
267        newZoom = 2;
268      } else {
269        // From 3x, reset to fit
270        // Use the slide's minimum zoom level which is the "fit" level
271        newZoom = pswp.currSlide.zoomLevels.fit || pswp.currSlide.zoomLevels.min || pswp.currSlide.min || 0.5;
272      }
273
274      // Get click position relative to the image
275      const destZoomPoint = {
276        x: e.originalEvent.clientX,
277        y: e.originalEvent.clientY,
278      };
279
280      // Zoom to the new level
281      pswp.currSlide.zoomTo(newZoom, destZoomPoint, 333, false);
282    });
283
284    // Register all UI elements
285    pswp.on("uiRegister", () => {
286      // Register caption UI element
287      pswp.ui.registerElement({
288        name: "caption",
289        order: 9,
290        isButton: false,
291        appendTo: "root",
292        html: "Caption text",
293        onInit: (el, pswp) => {
294          // Add caption styles
295          el.style.cssText = `
296            background: rgba(0, 0, 0, 0.75);
297            color: white;
298            font-size: 14px;
299            line-height: 1.5;
300            padding: 10px 15px;
301            position: absolute;
302            bottom: 0;
303            left: 0;
304            right: 0;
305            max-height: 30%;
306            overflow: auto;
307            text-align: center;
308          `;
309
310          pswp.on("change", () => {
311            const currSlideData = pswp.currSlide.data;
312            el.innerHTML = currSlideData.caption || "";
313            el.style.display = currSlideData.caption ? "block" : "none";
314          });
315        },
316      });
317
318      // Register download button
319      if (this.showDownloadButtonValue) {
320        pswp.ui.registerElement({
321          name: "download-button",
322          order: 8,
323          isButton: true,
324          tagName: "a",
325          title: "Download image",
326          ariaLabel: "Download image",
327          html: {
328            isCustomSVG: true,
329            inner:
330              '<path d="M20.5 14.3 17.1 18V10h-2.2v7.9l-3.4-3.6L10 16l6 6.1 6-6.1ZM23 23H9v2h14Z" id="pswp__icn-download"/>',
331            outlineID: "pswp__icn-download",
332          },
333          onInit: (el, pswp) => {
334            el.setAttribute("download", "");
335            el.setAttribute("target", "_blank");
336            el.setAttribute("rel", "noopener");
337
338            pswp.on("change", () => {
339              el.href = pswp.currSlide.data.src;
340              // Update download filename based on image source
341              const filename = pswp.currSlide.data.src.split("/").pop().split("?")[0];
342              el.setAttribute("download", filename || "image.jpg");
343            });
344          },
345        });
346      }
347
348      // Register zoom level indicator
349      if (this.showZoomIndicatorValue) {
350        pswp.ui.registerElement({
351          name: "zoom-level-indicator",
352          order: 9,
353          onInit: (el, pswp) => {
354            // Style the zoom indicator
355            el.style.cssText = `
356              background: rgba(0, 0, 0, 0.75);
357              margin-top: auto;
358              margin-bottom: auto;
359              align-items: center;
360              justify-content: center;
361              color: white;
362              height: fit-content;
363              font-size: 14px;
364              line-height: 1;
365              padding: 8px 12px;
366              border-radius: 8px;
367              pointer-events: none;
368              font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
369            `;
370
371            let hideTimeout;
372            let isInteracting = false;
373
374            const updateZoomLevel = () => {
375              if (pswp.currSlide) {
376                const zoomLevel = Math.round(pswp.currSlide.currZoomLevel * 100);
377                el.innerText = `${zoomLevel}%`;
378
379                // Always show during zoom or interaction
380                el.style.display = "block";
381                el.style.opacity = "1";
382
383                // Clear any existing timeout
384                if (hideTimeout) {
385                  clearTimeout(hideTimeout);
386                }
387
388                // Only hide after 2 seconds if at 100% and not interacting
389                if (zoomLevel === 100 && !isInteracting) {
390                  hideTimeout = setTimeout(() => {
391                    if (!isInteracting) {
392                      el.style.opacity = "0";
393                      setTimeout(() => {
394                        el.style.display = "none";
395                      }, 200);
396                    }
397                  }, 2000);
398                }
399              }
400            };
401
402            // Track mouse interaction
403            pswp.template.addEventListener("mouseenter", () => {
404              isInteracting = true;
405              if (hideTimeout) {
406                clearTimeout(hideTimeout);
407              }
408              // Show indicator when hovering
409              if (pswp.currSlide) {
410                el.style.display = "block";
411                el.style.opacity = "1";
412              }
413            });
414
415            pswp.template.addEventListener("mouseleave", () => {
416              isInteracting = false;
417              updateZoomLevel(); // This will set the hide timeout if at 100%
418            });
419
420            // Add transition for smooth fade
421            el.style.transition = "opacity 0.2s ease";
422
423            pswp.on("zoomPanUpdate", updateZoomLevel);
424            pswp.on("change", updateZoomLevel);
425            updateZoomLevel();
426          },
427        });
428      }
429
430      // Register dots/bullets navigation indicator
431      // Only show dots if enabled and there's more than one item
432      if (this.showDotsIndicatorValue && pswp.getNumItems() > 1) {
433        pswp.ui.registerElement({
434          name: "bulletsIndicator",
435          className: "pswp__bullets-indicator",
436          appendTo: "wrapper",
437          onInit: (el, pswp) => {
438            const bullets = [];
439            let bullet;
440            let prevIndex = -1;
441
442            // Style the bullets container
443            el.style.cssText = `
444              display: flex;
445              flex-direction: row;
446              align-items: center;
447              justify-content: center;
448              position: absolute;
449              bottom: 50px;
450              left: 50%;
451              transform: translateX(-50%);
452              z-index: 10;
453              pointer-events: auto;
454            `;
455
456            // Create bullets for each slide
457            for (let i = 0; i < pswp.getNumItems(); i++) {
458              bullet = document.createElement("button");
459              bullet.className = "pswp__bullet";
460              bullet.setAttribute("aria-label", `Go to slide ${i + 1}`);
461              bullet.style.cssText = `
462                width: 8px;
463                height: 8px;
464                border-radius: 50%;
465                background: rgba(255, 255, 255, 0.5);
466                margin: 0 4px;
467                padding: 0;
468                border: none;
469                cursor: pointer;
470                transition: all 0.2s ease;
471                box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.1);
472              `;
473              bullet.onmouseover = (e) => {
474                if (!e.target.classList.contains("pswp__bullet--active")) {
475                  e.target.style.background = "rgba(255, 255, 255, 0.7)";
476                }
477              };
478              bullet.onmouseout = (e) => {
479                if (!e.target.classList.contains("pswp__bullet--active")) {
480                  e.target.style.background = "rgba(255, 255, 255, 0.5)";
481                }
482              };
483              bullet.onclick = ((index) => {
484                return (e) => {
485                  pswp.goTo(index);
486                };
487              })(i);
488              el.appendChild(bullet);
489              bullets.push(bullet);
490            }
491
492            // Update bullets on slide change
493            pswp.on("change", () => {
494              if (prevIndex >= 0) {
495                bullets[prevIndex].classList.remove("pswp__bullet--active");
496                bullets[prevIndex].style.background = "rgba(255, 255, 255, 0.5)";
497                bullets[prevIndex].style.transform = "scale(1)";
498                bullets[prevIndex].style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.1)";
499              }
500              bullets[pswp.currIndex].classList.add("pswp__bullet--active");
501              bullets[pswp.currIndex].style.background = "rgba(255, 255, 255, 1)";
502              bullets[pswp.currIndex].style.transform = "scale(1.2)";
503              bullets[pswp.currIndex].style.boxShadow = "0 2px 6px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 0, 0, 0.2)";
504              prevIndex = pswp.currIndex;
505            });
506          },
507        });
508      }
509    });
510
511    // Update dimensions for items that need it
512    pswp.on("imageSrcChange", (e) => {
513      const { content, isLazy } = e;
514
515      if (content.data.needsUpdate && !isLazy) {
516        // Listen for the image load event
517        const updateDimensions = () => {
518          const img = content.pictureElement.querySelector("img");
519          if (img && img.naturalWidth && img.naturalHeight) {
520            content.width = img.naturalWidth;
521            content.height = img.naturalHeight;
522            content.updateContentSize(true);
523          }
524        };
525
526        if (content.pictureElement) {
527          content.pictureElement.addEventListener("load", updateDimensions, { once: true });
528        }
529      }
530    });
531
532    pswp.init();
533  }
534
535  get defaultOptions() {
536    return {
537      showHideAnimationType: "zoom",
538      pswpModule: () => import("photoswipe"),
539      preload: [1, 2],
540      wheelToZoom: true,
541      padding: { top: 20, bottom: 40, left: 20, right: 20 },
542      // Zoom settings to prevent closing on click
543      maxZoomLevel: 4,
544      getDoubleTapZoom: (isMouseClick, item) => {
545        // Smart zoom behavior that includes 100% as a stop
546        if (isMouseClick) {
547          // For mouse clicks, cycle through zoom levels
548          const currentZoom = item.instance.currSlide.currZoomLevel;
549          if (currentZoom < 0.95) {
550            return 1; // First zoom to 100% (actual size)
551          } else if (currentZoom < 1.5) {
552            return 2; // Then zoom to 2x
553          } else if (currentZoom < 2.5) {
554            return 3; // Then zoom to 3x
555          } else {
556            // Reset to fit - return the fit zoom level
557            const slide = item.instance.currSlide;
558            return slide.zoomLevels.fit || slide.zoomLevels.min || slide.min || 1;
559          }
560        }
561        // For touch devices, zoom to 100% first if below, otherwise 2x
562        const touchZoom = item.instance.currSlide.currZoomLevel;
563        return touchZoom < 0.95 ? 1 : 2;
564      },
565      // Prevent closing on vertical drag
566      closeOnVerticalDrag: false,
567      // Disable tap to close
568      tapAction: false,
569      // Allow click on background to close (but not on image)
570      clickToCloseNonZoomable: false,
571      // Ensure pinch to close is disabled
572      pinchToClose: false,
573    };
574  }
575
576  getAppendTarget() {
577    const parentDialog = this.element.closest("dialog[open]");
578
579    if (!parentDialog) {
580      return { appendTarget: document.body, hostDialog: null };
581    }
582
583    this.ensurePhotoSwipeHostStyles();
584    this.teardownPhotoSwipeHost();
585
586    const hostDialog = document.createElement("dialog");
587    hostDialog.className = "pswp-host-dialog";
588    hostDialog.setAttribute("data-lightbox-pswp-host", "true");
589    hostDialog.setAttribute("aria-label", "Image viewer");
590    document.body.appendChild(hostDialog);
591    hostDialog.showModal();
592
593    this.photoSwipeHostDialog = hostDialog;
594
595    return { appendTarget: hostDialog, hostDialog };
596  }
597
598  ensurePhotoSwipeHostStyles() {
599    const styleId = "pswp-host-dialog-styles";
600    if (document.getElementById(styleId)) return;
601
602    const style = document.createElement("style");
603    style.id = styleId;
604    style.textContent = `
605      dialog.pswp-host-dialog {
606        margin: 0;
607        padding: 0;
608        border: none;
609        width: 100vw;
610        max-width: none;
611        height: 100vh;
612        max-height: none;
613        background: transparent;
614        overflow: visible;
615      }
616
617      dialog.pswp-host-dialog::backdrop {
618        background: transparent;
619      }
620    `;
621    document.head.appendChild(style);
622  }
623
624  teardownPhotoSwipeHost(hostDialog = this.photoSwipeHostDialog) {
625    if (!hostDialog) return;
626
627    if (hostDialog.open) {
628      hostDialog.close();
629    }
630
631    hostDialog.remove();
632
633    if (this.photoSwipeHostDialog === hostDialog) {
634      this.photoSwipeHostDialog = null;
635    }
636  }
637
638  disconnect() {
639    this.teardownPhotoSwipeHost();
640
641    if (this.hasGalleryTarget) {
642      this.galleryTarget.removeEventListener("click", this.handleGalleryClick.bind(this));
643    }
644  }
645};

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.