mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-11 18:47:36 -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/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||||
@import 'components/statistics.css'; /* Add statistics component */
|
@import 'components/statistics.css'; /* Add statistics component */
|
||||||
@import 'components/sidebar.css'; /* Add sidebar component */
|
@import 'components/sidebar.css'; /* Add sidebar component */
|
||||||
|
@import 'components/media-viewer.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
|||||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { downloadManager } from '../managers/DownloadManager.js';
|
import { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
|
|
||||||
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
const ALLOWED_GEN_PARAM_KEYS = new Set([
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -112,6 +113,23 @@ class RecipeModal {
|
|||||||
|
|
||||||
// Set up document click handler to close edit fields
|
// Set up document click handler to close edit fields
|
||||||
document.addEventListener('click', (event) => {
|
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
|
// Handle title edit
|
||||||
const titleEditor = document.getElementById('recipeTitleEditor');
|
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||||
if (titleEditor && titleEditor.classList.contains('active') &&
|
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 { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
|
||||||
|
import { openMediaViewer } from '../MediaViewer.js';
|
||||||
|
|
||||||
export const showcaseListenerMetrics = {
|
export const showcaseListenerMetrics = {
|
||||||
wheelListeners: 0,
|
wheelListeners: 0,
|
||||||
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
|
|||||||
initMediaControlHandlers(carousel);
|
initMediaControlHandlers(carousel);
|
||||||
positionAllMediaControls(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
|
// Bind scroll-indicator click events
|
||||||
bindScrollIndicatorEvents(carousel);
|
bindScrollIndicatorEvents(carousel);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user