diff --git a/static/gallery.css b/static/gallery.css index e71c88a..c757e5e 100644 --- a/static/gallery.css +++ b/static/gallery.css @@ -1,12 +1,14 @@ -#gallery-modal, +#gallery-modal, .gallery-nav, .gallery-nav:focus, .gallery-nav:active, #gallery-modal-content, #gallery-close, -#gallery-download { - -webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */ - outline: none; /* Disable outline on click/focus */ +#gallery-download, +#gallery-info, +#gallery-play { + -webkit-tap-highlight-color: transparent; + outline: none; } @property --play-progress { @@ -15,258 +17,525 @@ initial-value: 0deg; } -/* Gallery Modal Styles */ #gallery-modal { - display: none; /* Hidden by default */ - position: fixed; - z-index: 3000; - top: 0; - left: 0; - width: 100%; - height: 100vh; - background-color: rgba(0,0,0,1); - justify-content: center; - align-items: center; - cursor: pointer; + display: none; + position: fixed; + z-index: 3000; + inset: 0; + background-color: rgba(0, 0, 0, 0.82); + background-image: + radial-gradient(circle at 50% 15%, rgba(42, 42, 42, 0.52), rgba(8, 8, 8, 0.95) 56%, #000 100%); + justify-content: center; + align-items: center; + overflow: hidden; + cursor: default; +} + +#gallery-stage { + position: absolute; + inset: 82px 22px 96px; + cursor: pointer; +} + +#gallery-modal-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: min(96vw, calc(100vw - 80px)); + max-height: calc(100vh - 190px); + object-fit: contain; + margin: auto; + display: block; + cursor: pointer; + border-radius: 12px; + box-shadow: 0 26px 60px rgba(0, 0, 0, 0.58); +} + +.gallery-top-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 14px 18px; + z-index: 130; + pointer-events: none; +} + +.gallery-actions-left, +.gallery-actions-right { + display: flex; + gap: 10px; + pointer-events: auto; +} + +.gallery-control { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.27); + background: rgba(0, 0, 0, 0.56); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 132; + font-size: 1.45rem; + font-weight: 500; + line-height: 1; + padding: 0; + text-decoration: none; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} + +.gallery-control:hover, +.gallery-control:focus-visible { + background: rgba(38, 38, 38, 0.88); + border-color: rgba(255, 255, 255, 0.65); + transform: translateY(-1px); +} + +#gallery-info.loading { + opacity: 0.62; +} + +#gallery-info { + font-size: 1.7rem; + font-weight: 700; +} + +#gallery-download, +#gallery-close { + font-size: 1.6rem; +} + +#loader-container { + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 3500; + pointer-events: none; +} + +#gallery-loader { + border: 4px solid rgba(255, 255, 255, 0.22); + border-top: 4px solid #fff; + border-radius: 50%; + width: 34px; + height: 34px; + animation: gallery-spin 0.9s linear infinite; +} + +@keyframes gallery-spin { + 0% { + transform: rotate(0deg); } - - #gallery-modal-content{ - max-width: 95%; - max-height: 95vh; - object-fit: contain; - margin: auto; - display: block; - cursor: pointer; + 100% { + transform: rotate(360deg); } - - /* Close Button */ - #gallery-close { - position: fixed; - top: 20px; - right: 20px; - width: 36px; - height: 36px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(0, 0, 0, 0.6); - color: #fff; - display: grid; - place-items: center; - cursor: pointer; - z-index: 120; - font-size: 1.1rem; +} + +.gallery-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 56px; + height: 56px; + min-width: 56px; + min-height: 56px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + color: #fff; + background: rgba(0, 0, 0, 0.54); + display: grid; + place-items: center; + padding: 0; + cursor: pointer; + z-index: 120; + font-size: 2.2rem; + font-weight: 400; + line-height: 1; + transition: opacity 0.25s ease, transform 0.25s ease, background 0.2s ease; +} + +.gallery-nav:hover, +.gallery-nav:focus-visible { + background: rgba(30, 30, 30, 0.9); +} + +.gallery-prev { + left: 18px; +} + +.gallery-next { + right: 18px; +} + +.gallery-next.is-close { + background: #fff; + border-color: #fff; + color: #101010; +} + +.gallery-nav.nav-hidden { + opacity: 0; + transform: translateY(-50%) scale(0.97); + pointer-events: none; +} + +#gallery-exif { + position: absolute; + top: 68px; + left: 18px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 0.85rem 0.92rem; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5); + width: min(360px, calc(100vw - 36px)); + max-height: calc(100vh - 228px); + overflow: auto; + display: none; + z-index: 140; +} + +#gallery-exif.open { + display: block; +} + +.exif-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 0.82rem; + margin-bottom: 8px; + line-height: 1.28; +} + +.exif-row:last-child { + margin-bottom: 0; +} + +.exif-key { + font-weight: 600; + color: rgba(255, 255, 255, 0.88); +} + +.exif-value { + text-align: right; + color: rgba(255, 255, 255, 0.98); + word-break: break-word; +} + +.exif-placeholder { + font-style: italic; + color: rgba(255, 255, 255, 0.76); +} + +.gallery-meta-bar { + position: absolute; + bottom: 16px; + left: 18px; + right: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + z-index: 120; + pointer-events: none; +} + +#gallery-filename, +#gallery-position { + color: #fff; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 8px; + padding: 0.45rem 0.72rem; + font-size: 0.82rem; + line-height: 1.2; + white-space: nowrap; +} + +#gallery-filename { + flex: 1; + min-width: 0; + position: relative; + isolation: isolate; + text-align: left; + overflow: hidden; + --filename-progress: 0%; +} + +#gallery-filename::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: + linear-gradient( + 90deg, + rgba(255, 255, 255, 0.95) 0%, + rgba(255, 255, 255, 0.95) var(--filename-progress), + rgba(0, 0, 0, 0.62) var(--filename-progress), + rgba(0, 0, 0, 0.62) 100% + ); + z-index: 0; +} + +#gallery-filename .filename-text { + position: relative; + z-index: 1; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #fff; +} + +#gallery-filename::after { + content: attr(data-filename); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 0.45rem 0.72rem; + box-sizing: border-box; + z-index: 2; + overflow: hidden; + clip-path: inset(0 calc(100% - var(--filename-progress)) 0 0); + text-overflow: ellipsis; + white-space: nowrap; + color: #0a0a0a; + pointer-events: none; +} + +#gallery-position { + min-width: 72px; + text-align: center; +} + +#gallery-play { + position: relative; + font-size: 1.2rem; + font-weight: 600; + --autoplay-duration: 5s; + --play-progress: 0deg; +} + +#gallery-play.playing::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 999px; + background: + conic-gradient( + rgba(255, 255, 255, 0.96) 0deg, + rgba(255, 255, 255, 0.96) var(--play-progress, 0deg), + rgba(255, 255, 255, 0.2) var(--play-progress, 0deg), + rgba(255, 255, 255, 0.2) 360deg + ); + mask: radial-gradient(ellipse at center, transparent 67%, #000 74%); + animation: none; + pointer-events: none; +} + +#gallery-play.playing.playing-anim::after { + animation: gallery-play-fill var(--autoplay-duration) linear forwards; +} + +@keyframes gallery-play-fill { + from { + --play-progress: 0deg; + } + to { + --play-progress: 360deg; + } +} + +@media (max-width: 860px) { + #gallery-stage { + inset: 74px 10px 110px; + } + + #gallery-modal-content { + max-width: calc(100vw - 20px); + max-height: calc(100vh - 210px); + border-radius: 10px; + } + + .gallery-top-bar { + padding: 10px; + gap: 8px; + flex-wrap: wrap; + } + + .gallery-actions-left, + .gallery-actions-right { + gap: 8px; + } + + .gallery-control { + width: 42px; + height: 42px; + min-width: 42px; + min-height: 42px; + font-size: 1.3rem; padding: 0; - margin: 0; } - #gallery-close:hover { - background: rgba(40, 40, 40, 0.85); - } - - /* Loader for gallery */ - #loader-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - z-index: 9999; /* Ensure it's on top */ - pointer-events: none; /* Allow interacting with close button while loader shows */ - } - - #gallery-loader { - border: 8px solid #f3f3f3; - border-top: 8px solid #525252; - border-radius: 50%; - width: 30px; - height: 30px; - animation: spin 3s linear infinite; - } - - /* Keyframes for spinner animation */ - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - .gallery-nav { - position: fixed; - top: 50%; - transform: translateY(-50%); - font-size: 3rem; - color: white; - background: rgba(0,0,0,0.1); - border: none; - padding: 1rem; - cursor: pointer; - z-index: 10; - transition: opacity 0.4s ease; - } - - .gallery-prev { left: 20px; } - .gallery-next { right: 20px; } - - .gallery-nav.nav-hidden { opacity: 0; } - - #gallery-info { - position: fixed; - top: 20px; - left: 20px; - width: 36px; - height: 36px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(0, 0, 0, 0.6); - color: #fff; - display: grid; - place-items: center; - cursor: pointer; - z-index: 120; - font-size: 1rem; + top: auto; + bottom: 62px; + transform: none; + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + font-size: 1.9rem; } - #gallery-download { - position: fixed; - top: 20px; - left: 64px; - width: 36px; - height: 36px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(0, 0, 0, 0.6); - color: #fff; - display: grid; - place-items: center; - cursor: pointer; - z-index: 120; - font-size: 1.25rem; - text-decoration: none; - line-height: 1; + .gallery-prev { + left: 10px; } - #gallery-download:hover { - background: rgba(40, 40, 40, 0.85); + .gallery-next { + right: 10px; } - #gallery-info.loading { - opacity: 0.65; + .gallery-nav.nav-hidden { + opacity: 0; + transform: translateY(5px); } #gallery-exif { - position: fixed; - top: 60px; - left: 20px; - background: rgba(0, 0, 0, 0.82); - color: #fff; - padding: 0.75rem 0.85rem; - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - max-width: 320px; - max-height: 50vh; - overflow: auto; - display: none; - z-index: 120; + top: 58px; + left: 10px; + width: calc(100vw - 20px); + max-height: calc(100vh - 224px); } - #gallery-exif.open { - display: block; + .gallery-meta-bar { + left: 10px; + right: 10px; + bottom: 10px; } - .exif-row { - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 0.9rem; - margin-bottom: 6px; - line-height: 1.3; - } - - .exif-row:last-child { - margin-bottom: 0; - } - - .exif-key { - font-weight: 600; - color: #e5e5e5; - } - - .exif-value { - text-align: right; - color: #fff; - } - - .exif-placeholder { - font-style: italic; - color: #d0d0d0; - } - - #gallery-filename { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - color: #fff; - background: rgba(0, 0, 0, 0.55); - padding: 0.4rem 0.75rem; - border-radius: 6px; - font-size: 0.95rem; - max-width: 90%; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - z-index: 20; - pointer-events: none; + #gallery-filename, + #gallery-position { + font-size: 0.76rem; + padding: 0.42rem 0.6rem; } #gallery-play { - position: fixed; - bottom: 20px; - right: 20px; + font-size: 1.05rem; + } + + #gallery-info { + font-size: 1.45rem; + } + + #gallery-download, + #gallery-close { + font-size: 1.4rem; + } +} + +@media (max-height: 520px) and (orientation: landscape) { + #gallery-stage { + inset: 44px 10px 60px; + } + + #gallery-modal-content { + max-width: calc(100vw - 20px); + max-height: calc(100vh - 112px); + border-radius: 8px; + } + + .gallery-top-bar { + padding: 8px 10px; + gap: 6px; + flex-wrap: nowrap; + } + + .gallery-actions-left, + .gallery-actions-right { + gap: 6px; + } + + .gallery-control { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + font-size: 1.1rem; + } + + #gallery-play { + font-size: 0.95rem; + } + + #gallery-info { + font-size: 1.22rem; + } + + #gallery-download, + #gallery-close { + font-size: 1.18rem; + } + + .gallery-nav { + top: 50%; + bottom: auto; + transform: translateY(-50%); width: 44px; height: 44px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(0, 0, 0, 0.6); - color: #fff; - display: grid; - place-items: center; - cursor: pointer; - z-index: 120; - font-size: 1.2rem; - line-height: 1; - padding: 0 0 0 2px; /* optical compensation for ▶ glyph whitespace */ - --autoplay-duration: 5s; - --play-progress: 0deg; - overflow: visible; + min-width: 44px; + min-height: 44px; + font-size: 1.7rem; } - #gallery-play.playing::after { - content: ''; - position: absolute; - inset: -8px; - border-radius: 50%; - background: - conic-gradient( - rgba(255, 255, 255, 0.9) 0deg, - rgba(255, 255, 255, 0.9) var(--play-progress, 0deg), - rgba(255, 255, 255, 0.2) var(--play-progress, 0deg), - rgba(255, 255, 255, 0.2) 360deg - ); - mask: radial-gradient(circle at center, transparent 60%, #000 70%); - animation: none; - pointer-events: none; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2); + .gallery-prev { + left: 8px; } - #gallery-play.playing.playing-anim::after { - animation: gallery-play-fill var(--autoplay-duration) linear forwards; + .gallery-next { + right: 8px; } - @keyframes gallery-play-fill { - from { --play-progress: 0deg; } - to { --play-progress: 360deg; } + .gallery-nav.nav-hidden { + opacity: 0; + transform: translateY(-50%) scale(0.96); } + + .gallery-meta-bar { + left: 10px; + right: 10px; + bottom: 8px; + gap: 8px; + } + + #gallery-filename, + #gallery-position { + padding: 0.32rem 0.52rem; + font-size: 0.7rem; + } + + #gallery-exif { + top: 50px; + left: 10px; + width: min(360px, calc(100vw - 20px)); + max-height: calc(100vh - 120px); + } +} diff --git a/static/gallery.js b/static/gallery.js index 85a846c..656c830 100644 --- a/static/gallery.js +++ b/static/gallery.js @@ -3,6 +3,17 @@ let pinchZoomOccurred = false; let initialPinchDistance = null; let initialScale = 1; let currentScale = 1; +let currentTranslateX = 0; +let currentTranslateY = 0; +let isPanning = false; +let panStartX = 0; +let panStartY = 0; +let panOriginX = 0; +let panOriginY = 0; +let pinchStartMidX = 0; +let pinchStartMidY = 0; +let pinchStartTranslateX = 0; +let pinchStartTranslateY = 0; let exifAbortController = null; let isGalleryLoading = false; let autoPlayTimer = null; @@ -179,15 +190,54 @@ function updateFilenameDisplay(relUrl) { if (!filenameEl) { return; } + const filenameTextEl = filenameEl.querySelector('.filename-text'); if (!relUrl) { - filenameEl.textContent = ''; + if (filenameTextEl) { + filenameTextEl.textContent = ''; + } else { + filenameEl.textContent = ''; + } + filenameEl.dataset.filename = ''; return; } const parts = relUrl.split('/'); const filename = parts[parts.length - 1] || relUrl; - filenameEl.textContent = filename; + if (filenameTextEl) { + filenameTextEl.textContent = filename; + } else { + filenameEl.textContent = filename; + } + filenameEl.dataset.filename = filename; +} + +function updateFilenameProgress() { + const filenameEl = document.getElementById('gallery-filename'); + if (!filenameEl) { + return; + } + + const total = currentGalleryImages.length; + const hasValidIndex = currentGalleryIndex >= 0 && currentGalleryIndex < total; + const progress = total > 0 && hasValidIndex + ? ((currentGalleryIndex + 1) / total) * 100 + : 0; + filenameEl.style.setProperty('--filename-progress', `${progress}%`); +} + +function updateGalleryPosition() { + const positionEl = document.getElementById('gallery-position'); + if (!positionEl) { + return; + } + + const total = currentGalleryImages.length; + if (total <= 0 || currentGalleryIndex < 0 || currentGalleryIndex >= total) { + positionEl.textContent = ''; + return; + } + positionEl.textContent = `${currentGalleryIndex + 1} / ${total}`; } function updateDownloadLink(relUrl) { @@ -196,15 +246,11 @@ function updateDownloadLink(relUrl) { return; } if (!relUrl) { - downloadBtn.removeAttribute('href'); - downloadBtn.removeAttribute('download'); downloadBtn.removeAttribute('data-relurl'); return; } // Store relUrl for the click handler (which fetches a short-lived token) downloadBtn.dataset.relurl = relUrl; - downloadBtn.removeAttribute('href'); - downloadBtn.removeAttribute('download'); } function updateNextButtonState() { @@ -214,23 +260,87 @@ function updateNextButtonState() { } const atLastImage = currentGalleryIndex >= currentGalleryImages.length - 1; - nextButton.textContent = atLastImage ? 'x' : '›'; + nextButton.textContent = atLastImage ? '\u00D7' : '\u203A'; nextButton.setAttribute('aria-label', atLastImage ? 'Close gallery' : 'Next image'); + nextButton.classList.toggle('is-close', atLastImage); } function showGalleryNav() { clearTimeout(navHideTimer); document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden')); + if (currentScale > 1.01) { + return; + } navHideTimer = setTimeout(() => { document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.add('nav-hidden')); }, 1000); } +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +function getPanBounds(image, scale) { + const stage = document.getElementById('gallery-stage'); + if (!stage || !image) { + return { maxX: 0, maxY: 0 }; + } + const baseWidth = image.clientWidth || 0; + const baseHeight = image.clientHeight || 0; + const scaledWidth = baseWidth * scale; + const scaledHeight = baseHeight * scale; + + return { + maxX: Math.max(0, (scaledWidth - stage.clientWidth) / 2), + maxY: Math.max(0, (scaledHeight - stage.clientHeight) / 2) + }; +} + +function applyImageTransform(image) { + if (!image) { + return; + } + const { maxX, maxY } = getPanBounds(image, currentScale); + currentTranslateX = clamp(currentTranslateX, -maxX, maxX); + currentTranslateY = clamp(currentTranslateY, -maxY, maxY); + image.style.transform = `translate(-50%, -50%) translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${currentScale})`; +} + +function resetImageTransformState(image = null) { + isPinching = false; + isPanning = false; + pinchZoomOccurred = false; + initialPinchDistance = null; + initialScale = 1; + currentScale = 1; + currentTranslateX = 0; + currentTranslateY = 0; + panStartX = 0; + panStartY = 0; + panOriginX = 0; + panOriginY = 0; + pinchStartMidX = 0; + pinchStartMidY = 0; + pinchStartTranslateX = 0; + pinchStartTranslateY = 0; + if (image) { + applyImageTransform(image); + } +} + function openGalleryModal(relUrl) { + const modal = document.getElementById('gallery-modal'); + if (!modal) { + return; + } document.body.style.overflow = 'hidden'; // Disable background scrolling. currentGalleryIndex = currentGalleryImages.indexOf(relUrl); + if (currentGalleryIndex < 0) { + currentGalleryIndex = 0; + } + modal.setAttribute('aria-hidden', 'false'); showGalleryImage(relUrl); - document.getElementById('gallery-modal').style.display = 'flex'; + modal.style.display = 'flex'; showGalleryNav(); } @@ -241,12 +351,16 @@ function showGalleryImage(relUrl) { isGalleryLoading = true; clearAutoPlayTimer(); updateNextButtonState(); + updateGalleryPosition(); + updateFilenameProgress(); loadExifData(relUrl); const fullUrl = '/media/' + relUrl; - const modal = document.getElementById('gallery-modal'); - const currentImage = document.getElementById('gallery-modal-content'); + const stage = document.getElementById('gallery-stage'); const loader = document.getElementById('loader-container'); - const closeBtn = document.getElementById('gallery-close'); + if (!stage || !loader) { + isGalleryLoading = false; + return; + } updateDownloadLink(relUrl); // Set a timeout for showing the loader after 500ms. let loaderTimeout = setTimeout(() => { @@ -262,8 +376,8 @@ function showGalleryImage(relUrl) { loader.style.display = 'none'; updateFilenameDisplay(relUrl); - // Select all current images in the gallery modal - const existingImages = document.querySelectorAll('#gallery-modal img'); + // Select all current images in the gallery stage + const existingImages = stage.querySelectorAll('img'); // Create a new image element for the transition const newImage = document.createElement('img'); @@ -272,27 +386,23 @@ function showGalleryImage(relUrl) { newImage.style.top = '50%'; // Center vertically newImage.style.left = '50%'; // Center horizontally newImage.style.transform = 'translate(-50%, -50%)'; // Ensure centering - newImage.style.maxWidth = '95vw'; // Prevent overflow on large screens - newImage.style.maxHeight = '95vh'; // Prevent overflow on tall screens newImage.style.transition = 'opacity 0.5s ease-in-out'; newImage.style.opacity = 0; // Start fully transparent newImage.id = 'gallery-modal-content'; + newImage.alt = ''; // Add pinch-to-zoom event listeners newImage.addEventListener('touchstart', handlePinchStart, { passive: false }); newImage.addEventListener('touchmove', handlePinchMove, { passive: false }); newImage.addEventListener('touchend', handlePinchEnd, { passive: false }); - newImage.addEventListener('click', handleGalleryImageClick); + newImage.addEventListener('touchcancel', handlePinchEnd, { passive: false }); // Reset pinch variables for the new image - initialPinchDistance = null; - initialScale = 1; - currentScale = 1; - pinchZoomOccurred = false; + resetImageTransformState(newImage); // Append new image on top of existing ones - modal.appendChild(newImage); + stage.appendChild(newImage); // Fade out all old images and fade in the new one setTimeout(() => { @@ -347,50 +457,88 @@ function preloadNextImage() { function closeGalleryModal() { clearTimeout(navHideTimer); document.querySelectorAll('.gallery-nav').forEach(btn => btn.classList.remove('nav-hidden')); - document.getElementById('gallery-modal').style.display = 'none'; + const modal = document.getElementById('gallery-modal'); + const stage = document.getElementById('gallery-stage'); + if (modal) { + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + } isGalleryLoading = false; setAutoPlay(false); updateDownloadLink(null); // remove all images - const existingImages = document.querySelectorAll('#gallery-modal img'); - existingImages.forEach(img => { + if (stage) { + const existingImages = stage.querySelectorAll('img'); + existingImages.forEach(img => { if (img.parentNode) { - img.parentNode.removeChild(img); + img.parentNode.removeChild(img); } - }); + }); + } const filenameEl = document.getElementById('gallery-filename'); if (filenameEl) { - filenameEl.textContent = ''; + const filenameTextEl = filenameEl.querySelector('.filename-text'); + if (filenameTextEl) { + filenameTextEl.textContent = ''; + } else { + filenameEl.textContent = ''; + } + filenameEl.dataset.filename = ''; + filenameEl.style.setProperty('--filename-progress', '0%'); + } + const positionEl = document.getElementById('gallery-position'); + if (positionEl) { + positionEl.textContent = ''; } resetExifPanel(true); + resetImageTransformState(); // Restore scrolling. document.body.style.overflow = ''; } -function handleGalleryImageClick(e) { - // If a pinch zoom occurred, don't change the image. - if (pinchZoomOccurred) { - pinchZoomOccurred = false; // Reset the flag for the next gesture. - e.stopPropagation(); +function navigateByScreenHalf(clientX) { + if (isGalleryLoading) { return; } - - if (e.target.classList.contains('gallery-next') || e.target.classList.contains('gallery-prev')) { - return; // Prevent conflict with navigation buttons. - } - const imgRect = this.getBoundingClientRect(); - if (e.clientX < imgRect.left + imgRect.width / 2) { - if (!isGalleryLoading && currentGalleryIndex > 0) { + const midpoint = window.innerWidth / 2; + if (clientX < midpoint) { + if (currentGalleryIndex > 0) { currentGalleryIndex--; showGalleryImage(currentGalleryImages[currentGalleryIndex]); } - } else { - if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) { - currentGalleryIndex++; - showGalleryImage(currentGalleryImages[currentGalleryIndex]); - } + return; } + + if (currentGalleryIndex < currentGalleryImages.length - 1) { + currentGalleryIndex++; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } else { + closeGalleryModal(); + } +} + +function handleGalleryScreenClick(e) { + // If a pinch zoom occurred, don't change the image. + if (pinchZoomOccurred) { + pinchZoomOccurred = false; + return; + } + if (currentScale > 1.01 || isPinching || isPanning) { + return; + } + + const target = e.target; + if (!target) { + return; + } + + // Do not interpret clicks on controls/panels as navigation. + if (target.closest('.gallery-control') || target.closest('.gallery-nav') || target.closest('#gallery-exif')) { + return; + } + + navigateByScreenHalf(e.clientX); } @@ -400,21 +548,24 @@ function initGallerySwipe() { let touchStartX = 0; let touchEndX = 0; const galleryModal = document.getElementById('gallery-modal'); - - galleryModal.addEventListener('mousemove', showGalleryNav, false); + if (!galleryModal) { + return; + } galleryModal.addEventListener('touchstart', function(e) { - showGalleryNav(); // If more than one finger is on screen, ignore swipe tracking. if (e.touches.length > 1) { return; } + if (currentScale > 1.01 || isPinching || isPanning) { + return; + } touchStartX = e.changedTouches[0].screenX; }, false); galleryModal.addEventListener('touchend', function(e) { // If the event was part of a multi-touch (pinch) gesture or a pinch was detected, skip swipe. - if (e.changedTouches.length > 1 || isPinching || pinchZoomOccurred) { + if (e.changedTouches.length > 1 || isPinching || isPanning || pinchZoomOccurred || currentScale > 1.01) { return; } touchEndX = e.changedTouches[0].screenX; @@ -441,7 +592,7 @@ function initGallerySwipe() { function clearAutoPlayTimer() { if (autoPlayTimer) { - clearInterval(autoPlayTimer); + clearTimeout(autoPlayTimer); autoPlayTimer = null; } } @@ -450,8 +601,9 @@ function setAutoPlay(active) { const playButton = document.getElementById('gallery-play'); isAutoPlay = active; if (playButton) { - playButton.textContent = active ? '\u23F8' : '\u25B6'; + playButton.textContent = active ? '\u2225' : '\u25B6'; playButton.setAttribute('aria-pressed', active ? 'true' : 'false'); + playButton.setAttribute('aria-label', active ? 'Pause slideshow' : 'Start slideshow'); playButton.classList.toggle('playing', active); } pendingAutoAdvance = false; @@ -541,10 +693,28 @@ function handlePinchStart(e) { if (e.touches.length === 2) { e.preventDefault(); isPinching = true; + isPanning = false; pinchZoomOccurred = false; // Reset at the start of a pinch gesture. const dx = e.touches[0].pageX - e.touches[1].pageX; const dy = e.touches[0].pageY - e.touches[1].pageY; initialPinchDistance = Math.sqrt(dx * dx + dy * dy); + initialScale = currentScale; + pinchStartMidX = (e.touches[0].pageX + e.touches[1].pageX) / 2; + pinchStartMidY = (e.touches[0].pageY + e.touches[1].pageY) / 2; + pinchStartTranslateX = currentTranslateX; + pinchStartTranslateY = currentTranslateY; + showGalleryNav(); + return; + } + + if (e.touches.length === 1 && currentScale > 1.01) { + e.preventDefault(); + isPanning = true; + panStartX = e.touches[0].pageX; + panStartY = e.touches[0].pageY; + panOriginX = currentTranslateX; + panOriginY = currentTranslateY; + showGalleryNav(); } } @@ -555,21 +725,97 @@ function handlePinchMove(e) { const dy = e.touches[0].pageY - e.touches[1].pageY; const currentDistance = Math.sqrt(dx * dx + dy * dy); let scale = currentDistance / initialPinchDistance; - scale = Math.max(0.5, Math.min(scale * initialScale, 3)); + scale = Math.max(1, Math.min(scale * initialScale, 4)); // If the scale has changed enough, mark that a pinch zoom occurred. - if (Math.abs(scale - initialScale) > 0.05) { + if (Math.abs(scale - currentScale) > 0.01) { pinchZoomOccurred = true; } currentScale = scale; - e.currentTarget.style.transform = `translate(-50%, -50%) scale(${scale})`; + const midX = (e.touches[0].pageX + e.touches[1].pageX) / 2; + const midY = (e.touches[0].pageY + e.touches[1].pageY) / 2; + currentTranslateX = pinchStartTranslateX + (midX - pinchStartMidX); + currentTranslateY = pinchStartTranslateY + (midY - pinchStartMidY); + applyImageTransform(e.currentTarget); + showGalleryNav(); + return; + } + + if (e.touches.length === 1 && isPanning && currentScale > 1.01) { + e.preventDefault(); + const deltaX = e.touches[0].pageX - panStartX; + const deltaY = e.touches[0].pageY - panStartY; + currentTranslateX = panOriginX + deltaX; + currentTranslateY = panOriginY + deltaY; + pinchZoomOccurred = true; + applyImageTransform(e.currentTarget); + showGalleryNav(); } } function handlePinchEnd(e) { - if (e.touches.length < 2) { - initialScale = currentScale; - initialPinchDistance = null; + if (e.touches.length === 1 && currentScale > 1.01) { + // Transition from pinch to one-finger pan. isPinching = false; + isPanning = true; + initialPinchDistance = null; + panStartX = e.touches[0].pageX; + panStartY = e.touches[0].pageY; + panOriginX = currentTranslateX; + panOriginY = currentTranslateY; + showGalleryNav(); + return; + } + + if (e.touches.length < 1) { + isPinching = false; + isPanning = false; + initialPinchDistance = null; + initialScale = currentScale; + const image = e.currentTarget; + if (currentScale <= 1.01) { + currentScale = 1; + currentTranslateX = 0; + currentTranslateY = 0; + } + applyImageTransform(image); + showGalleryNav(); + } +} + +function handleGalleryKeydown(e) { + const modal = document.getElementById('gallery-modal'); + if (!modal || modal.style.display !== 'flex') { + return; + } + + if (e.key === 'Escape') { + closeGalleryModal(); + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (!isGalleryLoading && currentGalleryIndex > 0) { + currentGalleryIndex--; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } + return; + } + + if (e.key === 'ArrowRight') { + e.preventDefault(); + if (!isGalleryLoading && currentGalleryIndex < currentGalleryImages.length - 1) { + currentGalleryIndex++; + showGalleryImage(currentGalleryImages[currentGalleryIndex]); + } else if (!isGalleryLoading) { + closeGalleryModal(); + } + return; + } + + if (e.key === ' ') { + e.preventDefault(); + setAutoPlay(!isAutoPlay); } } @@ -674,12 +920,8 @@ function initGallery() { initGallerySwipe(); initGalleryControls(); - // Close modal when clicking outside the image. - galleryModal.addEventListener('click', function(e) { - if (e.target === galleryModal) { - closeGalleryModal(); - } - }); + galleryModal.addEventListener('click', handleGalleryScreenClick); + document.addEventListener('keydown', handleGalleryKeydown); galleryModal.dataset.galleryBound = '1'; } diff --git a/templates/app.html b/templates/app.html index 719b44c..26803c3 100644 --- a/templates/app.html +++ b/templates/app.html @@ -203,16 +203,30 @@ -