From 5d3ab3bbf8b7207f3540cc4c7077a6e2d36f39c1 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 10 May 2026 22:08:53 +0800 Subject: [PATCH] feat(showcase): click-to-view full-size image/video in recipe and model modals (#926) - Add MediaViewer overlay for full-size image/video display with prev/next navigation, direction keys, counter, and adjacent preloading - Recipe modal: click preview image/video opens full-size viewer - Model showcase: click any example image/video opens viewer with full gallery navigation; blurred NSFW content opens directly to clear view - Use Map for DOM-index mapping instead of URL comparison to avoid index mismatch from lazy-loaded vs data-attribute URLs --- static/css/components/media-viewer.css | 124 +++++++++++ static/css/style.css | 1 + static/js/components/RecipeModal.js | 18 ++ static/js/components/shared/MediaViewer.js | 204 ++++++++++++++++++ .../shared/showcase/ShowcaseView.js | 22 ++ 5 files changed, 369 insertions(+) create mode 100644 static/css/components/media-viewer.css create mode 100644 static/js/components/shared/MediaViewer.js diff --git a/static/css/components/media-viewer.css b/static/css/components/media-viewer.css new file mode 100644 index 00000000..1867cd07 --- /dev/null +++ b/static/css/components/media-viewer.css @@ -0,0 +1,124 @@ +.media-viewer-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.3s ease; +} + +.media-viewer-overlay.active { + background: rgba(0, 0, 0, 0.92); +} + +.media-viewer-close { + position: fixed; + top: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10001; + transition: background 0.2s ease; + opacity: 0; +} + +.media-viewer-overlay.active .media-viewer-close { + opacity: 1; +} + +.media-viewer-close:hover { + background: rgba(255, 255, 255, 0.25); +} + +.media-viewer-content-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 90vw; + max-height: 95vh; + cursor: default; +} + +.media-viewer-media { + display: block; + max-width: 90vw; + max-height: 85vh; + object-fit: contain; + border-radius: 4px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); +} + +.media-viewer-video { + max-height: 80vh; +} + +.media-viewer-counter { + margin-top: 8px; + color: rgba(255, 255, 255, 0.5); + font-size: 0.85em; + text-align: center; + min-height: 1.2em; +} + +.media-viewer-title { + margin-top: 4px; + color: rgba(255, 255, 255, 0.7); + font-size: 0.9em; + text-align: center; + max-width: 90vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-viewer-nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 80px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: none; + color: #fff; + font-size: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 10001; + opacity: 0; + transition: opacity 0.2s ease, background 0.2s ease; +} + +.media-viewer-overlay.active .media-viewer-nav { + opacity: 1; +} + +.media-viewer-nav:hover { + background: rgba(255, 255, 255, 0.18); +} + +.media-viewer-prev { + left: 16px; +} + +.media-viewer-next { + right: 16px; +} diff --git a/static/css/style.css b/static/css/style.css index b3ab5987..17370b2b 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -39,6 +39,7 @@ @import 'components/keyboard-nav.css'; /* Add keyboard navigation component */ @import 'components/statistics.css'; /* Add statistics component */ @import 'components/sidebar.css'; /* Add sidebar component */ +@import 'components/media-viewer.css'; .initialization-notice { display: flex; diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 37e3bf45..f5209c2d 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js'; import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js'; import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; +import { openMediaViewer } from './shared/MediaViewer.js'; const ALLOWED_GEN_PARAM_KEYS = new Set([ 'prompt', @@ -112,6 +113,23 @@ class RecipeModal { // Set up document click handler to close edit fields document.addEventListener('click', (event) => { + const recipeModal = document.getElementById('recipeModal'); + if (recipeModal && recipeModal.style.display !== 'none') { + const mediaEl = event.target.closest('.recipe-preview-media'); + if (mediaEl && mediaEl.tagName) { + event.stopPropagation(); + const isVideo = mediaEl.tagName === 'VIDEO'; + const url = mediaEl.src || mediaEl.currentSrc; + if (url) { + openMediaViewer(url, { + type: isVideo ? 'video' : 'image', + title: document.getElementById('recipeModalTitle')?.textContent || '' + }); + } + return; + } + } + // Handle title edit const titleEditor = document.getElementById('recipeTitleEditor'); if (titleEditor && titleEditor.classList.contains('active') && diff --git a/static/js/components/shared/MediaViewer.js b/static/js/components/shared/MediaViewer.js new file mode 100644 index 00000000..2d88df88 --- /dev/null +++ b/static/js/components/shared/MediaViewer.js @@ -0,0 +1,204 @@ +let activeViewer = null; + +function createMediaElement(item) { + const { url, type = 'image' } = item; + if (type === 'video') { + const el = document.createElement('video'); + el.controls = true; + el.autoplay = true; + el.loop = true; + el.muted = true; + el.className = 'media-viewer-media media-viewer-video'; + el.src = url; + return el; + } + const el = document.createElement('img'); + el.className = 'media-viewer-media media-viewer-image'; + el.src = url; + el.alt = 'Full size preview'; + el.draggable = false; + return el; +} + +function preloadAdjacent(items, index) { + [index - 1, index + 1].forEach(i => { + if (i >= 0 && i < items.length && items[i].type !== 'video') { + const preload = new Image(); + preload.src = items[i].url; + } + }); +} + +export function openMediaViewer(arg1, arg2, arg3) { + closeMediaViewer(); + + let items, currentIndex, title = ''; + + if (Array.isArray(arg1)) { + items = arg1; + currentIndex = typeof arg2 === 'number' ? arg2 : 0; + title = (arg3 && arg3.title) || ''; + } else { + items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }]; + currentIndex = 0; + title = (arg2 && arg2.title) || ''; + } + + if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0; + + const overlay = document.createElement('div'); + overlay.className = 'media-viewer-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-label', title || 'Media viewer'); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'media-viewer-close'; + closeBtn.innerHTML = ''; + closeBtn.title = 'Close (Esc)'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + closeMediaViewer(); + }); + + const contentContainer = document.createElement('div'); + contentContainer.className = 'media-viewer-content-container'; + + let mediaElement = createMediaElement(items[currentIndex]); + contentContainer.appendChild(mediaElement); + + const hasNavigation = items.length > 1; + + const counter = document.createElement('div'); + counter.className = 'media-viewer-counter'; + counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : ''; + contentContainer.appendChild(counter); + + if (title) { + const titleBar = document.createElement('div'); + titleBar.className = 'media-viewer-title'; + titleBar.textContent = title; + contentContainer.appendChild(titleBar); + } + + let prevBtn, nextBtn; + if (hasNavigation) { + prevBtn = document.createElement('button'); + prevBtn.className = 'media-viewer-nav media-viewer-prev'; + prevBtn.innerHTML = ''; + prevBtn.title = 'Previous (←)'; + nextBtn = document.createElement('button'); + nextBtn.className = 'media-viewer-nav media-viewer-next'; + nextBtn.innerHTML = ''; + nextBtn.title = 'Next (→)'; + + const navigate = (delta) => { + const newIndex = (currentIndex + delta + items.length) % items.length; + currentIndex = newIndex; + + const oldMedia = contentContainer.querySelector('.media-viewer-media'); + const newMedia = createMediaElement(items[currentIndex]); + + if (oldMedia) { + if (oldMedia.tagName === 'VIDEO') { + oldMedia.pause(); + oldMedia.src = ''; + } + oldMedia.replaceWith(newMedia); + } + mediaElement = newMedia; + + counter.textContent = `${currentIndex + 1} / ${items.length}`; + preloadAdjacent(items, currentIndex); + }; + + prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); }); + nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); }); + + overlay.appendChild(prevBtn); + overlay.appendChild(nextBtn); + } + + overlay.appendChild(closeBtn); + overlay.appendChild(contentContainer); + document.body.appendChild(overlay); + + requestAnimationFrame(() => { + overlay.classList.add('active'); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + closeMediaViewer(); + } + }); + + const keyHandler = (e) => { + if (e.key === 'Escape') { + closeMediaViewer(); + return; + } + if (hasNavigation) { + if (e.key === 'ArrowLeft') { + e.stopPropagation(); + e.preventDefault(); + prevBtn.click(); + return; + } + if (e.key === 'ArrowRight') { + e.stopPropagation(); + e.preventDefault(); + nextBtn.click(); + return; + } + } + }; + document.addEventListener('keydown', keyHandler, true); + + activeViewer = { overlay, keyHandler }; + preloadAdjacent(items, currentIndex); + + if (items[currentIndex].type === 'video') { + const recipeVideo = document.getElementById('recipeModalVideo'); + if (recipeVideo && !recipeVideo.paused) { + recipeVideo.pause(); + } + } +} + +export function closeMediaViewer() { + if (!activeViewer) return; + + const { overlay, keyHandler } = activeViewer; + + const video = overlay.querySelector('video'); + if (video) { + video.pause(); + video.src = ''; + } + + const img = overlay.querySelector('img'); + if (img) { + img.src = ''; + } + + document.removeEventListener('keydown', keyHandler, true); + + overlay.classList.remove('active'); + overlay.addEventListener('transitionend', () => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }, { once: true }); + + setTimeout(() => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }, 500); + + activeViewer = null; +} + +export function isMediaViewerOpen() { + return activeViewer !== null; +} diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index 609673ef..e42d7a6f 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -17,6 +17,7 @@ import { import { generateMetadataPanel } from './MetadataPanel.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { getShowcaseUrl } from '../../../utils/civitaiUtils.js'; +import { openMediaViewer } from '../MediaViewer.js'; export const showcaseListenerMetrics = { wheelListeners: 0, @@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) { initMediaControlHandlers(carousel); positionAllMediaControls(carousel); + // Click-to-view: open full-size media viewer when clicking showcase images/videos + const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video'); + const allItems = []; + const elementIndexMap = new Map(); + viewerElements.forEach((el) => { + const isVideo = el.tagName === 'VIDEO'; + const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc; + if (url) { + elementIndexMap.set(el, allItems.length); + allItems.push({ url, type: isVideo ? 'video' : 'image' }); + } + }); + viewerElements.forEach((mediaEl) => { + const idx = elementIndexMap.get(mediaEl); + if (idx === undefined) return; + mediaEl.addEventListener('click', (e) => { + e.stopPropagation(); + openMediaViewer(allItems, idx); + }); + }); + // Bind scroll-indicator click events bindScrollIndicatorEvents(carousel);