/** * 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; // Pinch zoom state this.initialPinchDistance = 0; this.initialZoom = 1; this.pinchCenterX = 0; this.pinchCenterY = 0; this.initialTranslateX = 0; this.initialTranslateY = 0; } 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.15 : 0.15; // Get mouse position relative to modal content center const modalContent = this.shadowRoot.querySelector(".modal-content"); const rect = modalContent.getBoundingClientRect(); const mouseX = e.clientX - rect.left - rect.width / 2; const mouseY = e.clientY - rect.top - rect.height / 2; this.zoomToPoint(delta, mouseX, mouseY); } zoomToPoint(delta, pointX, pointY) { const modalImage = this.shadowRoot.querySelector(".modal-image"); if (!modalImage) return; const prevZoom = this.currentZoom; const newZoom = Math.max(1, Math.min(4, this.currentZoom + delta)); if (newZoom === prevZoom) return; // Calculate new translate to zoom towards point if (newZoom === 1) { this.translateX = 0; this.translateY = 0; } else { const zoomRatio = newZoom / prevZoom; this.translateX = pointX - (pointX - this.translateX) * zoomRatio; this.translateY = pointY - (pointY - this.translateY) * zoomRatio; } this.currentZoom = newZoom; this.updateImageTransform(false); modalImage.style.cursor = this.currentZoom > 1 ? "grab" : "default"; } handleTouchStart(e) { if (e.touches.length === 2) { // Pinch zoom start e.preventDefault(); this.isDragging = false; // Stop any ongoing drag this.initialPinchDistance = this.getPinchDistance(e.touches); this.initialZoom = this.currentZoom; this.initialTranslateX = this.translateX; this.initialTranslateY = this.translateY; // Get pinch center relative to viewport const modalContent = this.shadowRoot.querySelector(".modal-content"); const rect = modalContent.getBoundingClientRect(); this.pinchCenterX = ((e.touches[0].clientX + e.touches[1].clientX) / 2) - rect.left - rect.width / 2; this.pinchCenterY = ((e.touches[0].clientY + e.touches[1].clientY) / 2) - rect.top - rect.height / 2; } else if (e.touches.length === 1 && this.currentZoom > 1) { // Single touch drag when zoomed this.startDrag(e); } } handleTouchMove(e) { if (e.touches.length === 2 && this.initialPinchDistance > 0) { // Pinch zoom with zoom-to-point e.preventDefault(); const currentDistance = this.getPinchDistance(e.touches); const scale = currentDistance / this.initialPinchDistance; const newZoom = Math.max(1, Math.min(4, this.initialZoom * scale)); const prevZoom = this.currentZoom; this.currentZoom = newZoom; if (this.currentZoom === 1) { this.translateX = 0; this.translateY = 0; } else { // Adjust translate to zoom towards pinch center const zoomRatio = this.currentZoom / this.initialZoom; this.translateX = this.pinchCenterX - (this.pinchCenterX - this.initialTranslateX) * zoomRatio; this.translateY = this.pinchCenterY - (this.pinchCenterY - this.initialTranslateY) * zoomRatio; } this.updateImageTransform(false); // No transition during pinch 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; // Use no transition during drag for smooth movement const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { modalImage.style.transition = "none"; modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`; } } 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(useTransition = true) { const modalImage = this.shadowRoot.querySelector(".modal-image"); if (modalImage) { if (!useTransition) { modalImage.style.transition = "none"; } modalImage.style.transform = `scale(${this.currentZoom}) translate(${this.translateX / this.currentZoom}px, ${this.translateY / this.currentZoom}px)`; // Force reflow and restore transition if (!useTransition) { modalImage.offsetHeight; // Force reflow modalImage.style.transition = "transform 0.2s ease"; } } } 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 all state this.currentZoom = 1; this.translateX = 0; this.translateY = 0; this.isDragging = false; this.initialPinchDistance = 0; this.initialZoom = 1; this.initialTranslateX = 0; this.initialTranslateY = 0; // Reset transform without transition modalImage.style.transition = "none"; modalImage.style.transform = "scale(1) translate(0px, 0px)"; modalImage.style.cursor = "default"; // Force reflow then restore transition modalImage.offsetHeight; modalImage.style.transition = "transform 0.2s ease"; 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) { // Reset all state this.currentZoom = 1; this.translateX = 0; this.translateY = 0; this.isDragging = false; this.initialPinchDistance = 0; this.initialZoom = 1; this.initialTranslateX = 0; this.initialTranslateY = 0; // Ensure transition is enabled for smooth reset animation modalImage.style.transition = "transform 0.3s ease"; modalImage.style.transform = "scale(1) translate(0px, 0px)"; modalImage.style.cursor = "default"; } } render() { const imagesHtml = this.images .map( (img, index) => ` ` ) .join(""); this.shadowRoot.innerHTML = `