mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-12 04:37:51 -03:00
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<Element, number> for DOM-index mapping instead of URL comparison to avoid index mismatch from lazy-loaded vs data-attribute URLs
This commit is contained in:
124
static/css/components/media-viewer.css
Normal file
124
static/css/components/media-viewer.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
204
static/js/components/shared/MediaViewer.js
Normal file
204
static/js/components/shared/MediaViewer.js
Normal file
@@ -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 = '<i class="fas fa-times"></i>';
|
||||
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 = '<i class="fas fa-chevron-left"></i>';
|
||||
prevBtn.title = 'Previous (←)';
|
||||
nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'media-viewer-nav media-viewer-next';
|
||||
nextBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user