Files
ComfyUI-Lora-Manager/static/js/components/shared/MediaViewer.js
Will Miao 5d3ab3bbf8 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
2026-05-10 22:22:24 +08:00

205 lines
6.1 KiB
JavaScript

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;
}