import { Component } from "/js/components/component.js"; import { html, render } from "/js/lib/lit-html.js"; import { isSafari } from "/js/utils.js"; import { cdnImageUrl } from "/js/dataHelpers.js"; import "/js/components/lightbox-image-group.js"; import "/js/components/app-icon.js"; const MIN_ASPECT_RATIO = 2 / 3; const MAX_ASPECT_RATIO = 3 / 2; const ITEM_GAP_PX = 8; function clampAspectRatio(rawRatio) { if (!Number.isFinite(rawRatio) || rawRatio <= 0) { return { ratio: 1, isCropped: false }; } const ratio = Math.min( MAX_ASPECT_RATIO, Math.max(MIN_ASPECT_RATIO, rawRatio), ); return { ratio, isCropped: ratio !== rawRatio }; } export class ImageCarousel extends Component { connectedCallback() { if (this._initialized) return; this._initialized = true; this.currentIndex = 0; if (!Array.isArray(this._images)) this._images = []; this.render(); this._scroller = this.querySelector(".carousel-scroller"); this._disposers = []; if (this._scroller) { this._disposers.push(this._bindScroll(), this._bindKeyboard()); if (isSafari()) { this._disposers.push(this._bindWheel()); } } } disconnectedCallback() { this._disposers?.forEach((dispose) => dispose()); this._disposers = []; } set images(value) { this._images = Array.isArray(value) ? value : []; if (this._initialized) { this.currentIndex = 0; this.render(); } } get images() { return this._images ?? []; } get _slides() { return this.querySelectorAll('[data-testid="carousel-slide"]'); } render() { const numImages = this.images.length; render( html` `, this, ); } _openLightbox(index) { const images = Array.from(this.querySelectorAll("img.carousel-image")); const lightboxDialog = document.createElement("lightbox-dialog"); lightboxDialog.images = images; lightboxDialog.currentIndex = index; lightboxDialog.addEventListener("close", () => { lightboxDialog.remove(); }); document.body.appendChild(lightboxDialog); lightboxDialog.open(); } _getSlideWidths() { return Array.from(this._slides, (slide) => slide.offsetWidth); } _getOffsetForIndex(index) { const widths = this._getSlideWidths(); let offset = 0; for (let i = 0; i < index; i += 1) { offset += widths[i] + ITEM_GAP_PX; } return offset; } _getActiveIndexFromScroll() { const widths = this._getSlideWidths(); const scrollLeft = this._scroller.scrollLeft; let cumulative = 0; let bestIndex = 0; let bestDistance = Infinity; for (let i = 0; i < widths.length; i += 1) { const distance = Math.abs(cumulative - scrollLeft); if (distance < bestDistance) { bestDistance = distance; bestIndex = i; } cumulative += widths[i] + ITEM_GAP_PX; } return bestIndex; } _updateCurrentIndex(index) { if (index === this.currentIndex) return; this.currentIndex = index; this.render(); } _bindScroll() { const onScrollEnd = () => { const index = this._getActiveIndexFromScroll(); this._updateCurrentIndex(index); }; this._scroller.addEventListener("scrollend", onScrollEnd); return () => this._scroller.removeEventListener("scrollend", onScrollEnd); } _bindKeyboard() { const onKeyDown = (event) => { if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; if (!this.contains(document.activeElement)) return; event.preventDefault(); const offset = event.key === "ArrowRight" ? 1 : -1; const nextIndex = Math.max( 0, Math.min(this.images.length - 1, this.currentIndex + offset), ); if (nextIndex === this.currentIndex) return; this._updateCurrentIndex(nextIndex); this._scroller.scrollTo({ left: this._getOffsetForIndex(nextIndex), behavior: "smooth", }); const slide = this._slides[nextIndex]; if (slide) slide.focus({ preventScroll: true }); }; this.addEventListener("keydown", onKeyDown); return () => this.removeEventListener("keydown", onKeyDown); } _bindWheel() { const onWheel = (event) => { if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; event.preventDefault(); this._scroller.scrollLeft += event.deltaX; this._updateCurrentIndex(this._getActiveIndexFromScroll()); }; this._scroller.addEventListener("wheel", onWheel, { passive: false }); return () => this._scroller.removeEventListener("wheel", onWheel); } } ImageCarousel.register();