Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
7.5 kB · 237 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238import { 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` <div class="image-carousel-container" role="group" aria-roledescription="carousel" aria-label=${`Image gallery, ${numImages} images`} data-testid="image-carousel-container" > <div class="carousel-scroller"> ${this.images.map((image, index) => { const rawRatio = image.aspectRatio ? image.aspectRatio.width / image.aspectRatio.height : 1; const { ratio, isCropped } = clampAspectRatio(rawRatio); const ariaLabel = image.alt && image.alt.length > 0 ? image.alt : `Image ${index + 1} of ${numImages}`; const isActive = index === this.currentIndex; return html` <button type="button" class="carousel-slide" data-testid="carousel-slide" data-teststate=${isActive ? "active" : "inactive"} data-index=${index} role="group" aria-roledescription="slide" aria-label=${ariaLabel} tabindex=${isActive ? "0" : "-1"} style=${`aspect-ratio: ${ratio};`} @click=${(event) => { event.preventDefault(); event.stopPropagation(); this._openLightbox(index); }} > <img class="carousel-image" src=${cdnImageUrl(image.thumb)} data-lightbox-src=${cdnImageUrl( image.fullsize ?? image.thumb, )} alt=${image.alt ?? ""} loading="lazy" draggable="false" /> ${numImages > 1 ? html`<span class="carousel-counter" data-testid="carousel-counter" aria-hidden="true" >${index + 1}/${numImages}</span >` : ""} ${image.alt ? html`<span class="alt-indicator" data-testid="image-alt-badge" >ALT</span >` : ""} ${isCropped ? html`<span class="crop-indicator" data-testid="image-crop-badge" aria-label="Image cropped" > <app-icon icon="crop-indicator"></app-icon> </span>` : ""} </button> `; })} </div> </div> `, 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();