/** * Image Gallery Component * Grid of images that open in a fullscreen modal on click */ class ImageGallery extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); // Zoom and pan state this.currentZoom = 1; this.translateX = 0; this.translateY = 0; this.isDragging = false; this.startX = 0; this.startY = 0; this.lastTranslateX = 0; this.lastTranslateY = 0; // Pinch zoom state this.initialPinchDistance = 0; this.initialZoom = 1; } connectedCallback() { this.render(); this.setupEventListeners(); } get images() { const imagesAttr = this.getAttribute("images"); return imagesAttr ? imagesAttr.split(",").map((img) => img.trim()) : []; } setupEventListeners() { const imageItems = this.shadowRoot.querySelectorAll(".gallery-image"); imageItems.forEach((img, index) => { img.addEventListener("click", () => { this.openModal(index); }); }); // Modal controls const closeBtn = this.shadowRoot.querySelector(".modal-close"); const modal = this.shadowRoot.querySelector(".modal-overlay"); if (closeBtn) { closeBtn.addEventListener("click", () => this.closeModal()); } if (modal) { modal.addEventListener("click", (e) => { if (e.target === modal) { this.closeModal(); } }); } // Keyboard support document.addEventListener("keydown", (e) => { if (e.key === "Escape") { this.closeModal(); } }); // Zoom controls const zoomIn = this.shadowRoot.querySelector(".zoom-in"); const zoomOut = this.shadowRoot.querySelector(".zoom-out"); const resetZoom = this.shadowRoot.querySelector(".zoom-reset"); if (zoomIn) { zoomIn.addEventListener("click", () => this.zoom(0.25)); } if (zoomOut) { zoomOut.addEventListener("click", () => this.zoom(-0.25)); } if (resetZoom) { resetZoom.addEventListener("click", () => this.resetZoom()); } // Drag/pan functionality const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { // Mouse events modalImage.addEventListener("mousedown", (e) => this.startDrag(e)); modalImage.addEventListener("mousemove", (e) => this.drag(e)); modalImage.addEventListener("mouseup", () => this.endDrag()); modalImage.addEventListener("mouseleave", () => this.endDrag()); // Touch events for mobile modalImage.addEventListener("touchstart", (e) => this.startDrag(e), { passive: false }); modalImage.addEventListener("touchmove", (e) => this.drag(e), { passive: false }); modalImage.addEventListener("touchend", () => this.endDrag()); // Prevent default drag behavior modalImage.addEventListener("dragstart", (e) => e.preventDefault()); // Mouse wheel zoom modalImage.addEventListener("wheel", (e) => this.handleWheel(e), { passive: false }); } // Pinch zoom for touch devices on modal content const modalContent = this.shadowRoot.querySelector(".modal-content"); if (modalContent) { modalContent.addEventListener("touchstart", (e) => this.handleTouchStart(e), { passive: false }); modalContent.addEventListener("touchmove", (e) => this.handleTouchMove(e), { passive: false }); modalContent.addEventListener("touchend", (e) => this.handleTouchEnd(e)); // Prevent default browser gestures on the modal modalContent.addEventListener("gesturestart", (e) => e.preventDefault()); modalContent.addEventListener("gesturechange", (e) => e.preventDefault()); modalContent.addEventListener("gestureend", (e) => e.preventDefault()); } } handleWheel(e) { e.preventDefault(); e.stopPropagation(); const delta = e.deltaY > 0 ? -0.1 : 0.1; this.zoom(delta); } handleTouchStart(e) { if (e.touches.length === 2) { // Pinch zoom start e.preventDefault(); this.initialPinchDistance = this.getPinchDistance(e.touches); this.initialZoom = this.currentZoom; } else if (e.touches.length === 1 && this.currentZoom > 1) { // Single touch drag when zoomed this.startDrag(e); } } handleTouchMove(e) { if (e.touches.length === 2) { // Pinch zoom e.preventDefault(); const currentDistance = this.getPinchDistance(e.touches); const scale = currentDistance / this.initialPinchDistance; const newZoom = Math.max(1, Math.min(4, this.initialZoom * scale)); this.currentZoom = newZoom; if (this.currentZoom === 1) { this.translateX = 0; this.translateY = 0; } this.updateImageTransform(); const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; } } else if (e.touches.length === 1 && this.isDragging) { // Single touch drag this.drag(e); } } handleTouchEnd(e) { this.initialPinchDistance = 0; this.endDrag(); } getPinchDistance(touches) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.sqrt(dx * dx + dy * dy); } startDrag(e) { // Only enable drag when zoomed in if (this.currentZoom <= 1) return; this.isDragging = true; if (e.type === "touchstart") { this.startX = e.touches[0].clientX - this.translateX; this.startY = e.touches[0].clientY - this.translateY; } else { this.startX = e.clientX - this.translateX; this.startY = e.clientY - this.translateY; } const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { modalImage.style.cursor = "grabbing"; modalImage.style.transition = "none"; } } drag(e) { if (!this.isDragging) return; e.preventDefault(); let clientX, clientY; if (e.type === "touchmove") { clientX = e.touches[0].clientX; clientY = e.touches[0].clientY; } else { clientX = e.clientX; clientY = e.clientY; } this.translateX = clientX - this.startX; this.translateY = clientY - this.startY; this.updateImageTransform(); } endDrag() { if (!this.isDragging) return; this.isDragging = false; const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; modalImage.style.transition = "transform 0.2s ease"; } } updateImageTransform() { const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`; } } openModal(index) { const modal = this.shadowRoot.querySelector(".modal-overlay"); const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modal && modalImage && this.images[index]) { modalImage.src = this.images[index]; // Reset zoom and pan this.currentZoom = 1; this.translateX = 0; this.translateY = 0; this.updateImageTransform(); modalImage.style.cursor = "default"; modal.classList.add("open"); document.body.style.overflow = "hidden"; } } closeModal() { const modal = this.shadowRoot.querySelector(".modal-overlay"); if (modal) { modal.classList.remove("open"); document.body.style.overflow = ""; } } zoom(delta) { const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { const prevZoom = this.currentZoom; this.currentZoom = Math.max(1, Math.min(4, this.currentZoom + delta)); // If zooming out to 1x, reset position if (this.currentZoom === 1) { this.translateX = 0; this.translateY = 0; } this.updateImageTransform(); modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; } } resetZoom() { const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { this.currentZoom = 1; this.translateX = 0; this.translateY = 0; this.updateImageTransform(); modalImage.style.cursor = "default"; } } render() { const imagesHtml = this.images .map( (img, index) => ` ` ) .join(""); this.shadowRoot.innerHTML = `