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.