feat(ui): redesign model modal showcase as on-demand gallery

This commit is contained in:
Will Miao
2026-08-18 17:32:39 +08:00
parent 4a6042d0b4
commit d2f50f26f1
20 changed files with 22138 additions and 21407 deletions
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -1533,6 +1533,30 @@
"examples": "Loading examples...", "examples": "Loading examples...",
"versions": "Loading versions..." "versions": "Loading versions..."
}, },
"showcase": {
"hiddenBySfw": "{count} hidden by SFW-only setting",
"showExamples": "Show examples",
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
"or": "or",
"selectFiles": "Select Files",
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importing files...",
"noSupportedFiles": "No supported files selected. Please select image or video files.",
"allFiltered": "All example images are filtered due to NSFW content settings",
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
"changeInSettings": "You can change this in Settings",
"nsfwMature": "Mature Content",
"nsfwR": "R-rated Content",
"nsfwX": "X-rated Content",
"nsfwXxx": "XXX-rated Content"
},
"versions": { "versions": {
"heading": "Model versions", "heading": "Model versions",
"copy": "Track and manage every version of this model in one place.", "copy": "Track and manage every version of this model in one place.",
@@ -2333,4 +2357,4 @@
"retry": "Retry" "retry": "Retry"
} }
} }
} }
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+2325 -2301
View File
File diff suppressed because it is too large Load Diff
+274 -47
View File
@@ -4,19 +4,268 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
.carousel { /* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
transition: max-height 0.3s ease-in-out;
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
.gallery-indicator-bar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.gallery-preview-thumb {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
overflow: hidden;
flex-shrink: 0;
background: var(--bg-color);
}
.gallery-preview-thumb img,
.gallery-preview-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-indicator-bar .gallery-show-btn {
flex: 1;
justify-content: flex-start;
}
.gallery-indicator-bar .gallery-import-btn {
margin-left: auto;
}
/* Expanded gallery toolbar */
.gallery-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
/* Position badge floats over the main media, bottom-right */
.gallery-position-badge {
position: absolute;
right: var(--space-2);
bottom: var(--space-2);
z-index: 6;
padding: 2px 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.8em;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
/* While the gallery is expanded the thumbnail strip sits in the modal's
bottom-right corner, where the back-to-top button would overlap it */
.modal-content.showcase-expanded .back-to-top {
display: none;
}
.gallery-toolbar .gallery-import-btn {
margin-left: auto;
}
.gallery-show-btn,
.gallery-import-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
color: var(--text-color);
font-size: 0.9em;
cursor: pointer;
transition: var(--transition-base);
}
.gallery-show-btn:hover,
.gallery-import-btn:hover {
border-color: var(--lora-accent);
color: var(--lora-accent);
}
.nsfw-filter-notification {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Main viewer — the container hugs the active media's aspect ratio
(--media-aspect = width/height, set per item) so no dead space remains.
overflow: hidden also clips the hoisted metadata panel while it is
translated below the bottom edge, so it never extends the modal's
scrollable height (which caused a scroll jump when it appeared) */
.gallery-main {
position: relative;
overflow: hidden;
border-radius: var(--border-radius-sm);
}
.main-media-container {
position: relative;
margin: 0 auto;
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
aspect-ratio: var(--media-aspect, 1.3333);
max-height: min(75vh, 800px);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
} }
.carousel.collapsed { .main-media-container .media-wrapper {
max-height: 0; width: 100%;
height: 100%;
margin-bottom: 0;
} }
.carousel-container { .main-media-container .media-wrapper img,
.main-media-container .media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
cursor: zoom-in;
}
/* Nav buttons float over the media, visible on hover */
.gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 6;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
opacity: 0;
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
pointer-events: none;
}
.gallery-nav.prev {
left: var(--space-2);
}
.gallery-nav.next {
right: var(--space-2);
}
.gallery-main:hover .gallery-nav,
.gallery-nav:focus-visible {
opacity: 0.9;
pointer-events: auto;
}
.gallery-nav:hover {
opacity: 1;
border-color: var(--lora-accent);
color: var(--lora-accent);
}
/* Thumbnail strip */
.gallery-strip {
display: flex; display: flex;
flex-direction: column; gap: var(--space-1);
gap: var(--space-2); margin-top: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-1);
}
.gallery-thumb {
position: relative;
width: 72px;
height: 72px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--lora-surface);
cursor: pointer;
padding: 0;
transition: border-color 0.15s ease;
}
.gallery-thumb:hover {
border-color: var(--text-color);
}
.gallery-thumb.active {
border-color: var(--lora-accent);
}
.gallery-thumb .thumb-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-thumb .thumb-media.blurred {
filter: blur(8px);
}
.gallery-thumb .thumb-video-badge,
.gallery-thumb .thumb-nsfw-badge {
position: absolute;
bottom: 3px;
right: 3px;
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius-xs);
padding: 1px 4px;
pointer-events: none;
}
.gallery-thumb .thumb-nsfw-badge {
top: 3px;
bottom: auto;
}
.gallery-strip::-webkit-scrollbar {
height: 6px;
}
.gallery-strip::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* Inline import zone toggled from the toolbar */
.gallery-import-zone {
margin-top: var(--space-2);
}
.gallery-import-zone.hidden {
display: none;
}
.gallery-import-zone .example-import-area {
margin-top: 0;
} }
.media-wrapper { .media-wrapper {
@@ -31,16 +280,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
.media-wrapper img,
.media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.no-examples { .no-examples {
text-align: center; text-align: center;
padding: var(--space-3); padding: var(--space-3);
@@ -48,11 +287,6 @@
opacity: 0.7; opacity: 0.7;
} }
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */ /* Add styles for blurred showcase content */
.nsfw-media-wrapper { .nsfw-media-wrapper {
position: relative; position: relative;
@@ -217,6 +451,24 @@
pointer-events: auto; pointer-events: auto;
} }
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
.gallery-main > .image-metadata-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 7;
max-height: 60%;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
}
.gallery-main > .image-metadata-panel.visible {
transform: translateY(0);
opacity: 0.98;
pointer-events: auto;
}
/* Adjust to dark theme */ /* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
@@ -388,31 +640,6 @@
opacity: 0.8; opacity: 0.8;
} }
/* Scroll Indicator */
.scroll-indicator {
cursor: pointer;
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em;
color: var(--text-color);
}
.lazy { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
+21 -23
View File
@@ -1,7 +1,6 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
@@ -304,10 +303,20 @@ function handleCardClick(card, modelType) {
} }
} }
// Preview URL is not in the dataset; read it from the card's rendered media
function getCardPreviewUrl(card) {
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
if (!cardMedia) return '';
return cardMedia.tagName === 'VIDEO'
? (cardMedia.dataset.src || '')
: (cardMedia.src || '');
}
async function showModelModalFromCard(card, modelType) { async function showModelModalFromCard(card, modelType) {
// Create model metadata object // Create model metadata object
const modelMeta = { const modelMeta = {
sha256: card.dataset.sha256, sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath, file_path: card.dataset.filepath,
model_name: card.dataset.name, model_name: card.dataset.name,
file_name: card.dataset.file_name, file_name: card.dataset.file_name,
@@ -397,6 +406,7 @@ function showExampleAccessModal(card, modelType) {
// Get the model data from card dataset (works for both lora and checkpoint) // Get the model data from card dataset (works for both lora and checkpoint)
const modelMeta = { const modelMeta = {
sha256: card.dataset.sha256, sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath, file_path: card.dataset.filepath,
model_name: card.dataset.name, model_name: card.dataset.name,
file_name: card.dataset.file_name, file_name: card.dataset.file_name,
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
// Show the model modal // Show the model modal
await showModelModal(modelMeta, modelType); await showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible // Reveal the import entry once the modal content has rendered
setTimeout(() => { setTimeout(() => {
const importArea = document.querySelector('.example-import-area'); // Gallery mode: the import button is always visible — expand the zone
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
if (importBtn) {
importBtn.click();
return;
}
// Empty state: the import area is the whole tab content — scroll to it
const importArea = document.querySelector('#modelModal .example-import-area');
if (importArea) { if (importArea) {
const showcaseTab = document.getElementById('showcase-tab'); importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (showcaseTab) {
// First make sure showcase tab is visible
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
if (tabBtn && !tabBtn.classList.contains('active')) {
tabBtn.click();
}
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
toggleShowcase(scrollIndicator);
}
}
// Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' });
}
} }
}, 500); }, 500);
}; };
+9 -11
View File
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop, scrollToTop,
loadExampleImages loadExampleImages
} from './showcase/ShowcaseView.js'; } from './showcase/ShowcaseView.js';
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
updateCardUpdateAvailability(hasUpdate); updateCardUpdateAvailability(hasUpdate);
} }
let showcaseCleanup;
const onCloseCallback = function () { const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA // Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
modalElement.removeEventListener('click', modalElement._clickHandler); modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler; delete modalElement._clickHandler;
} }
if (showcaseCleanup) {
showcaseCleanup();
showcaseCleanup = null;
}
cleanupNavigationShortcuts(); cleanupNavigationShortcuts();
}; };
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
if (modelType === 'embeddings' && modelWithFullData.folder) { if (modelType === 'embeddings' && modelWithFullData.folder) {
activeModalElement.dataset.folder = modelWithFullData.folder; activeModalElement.dataset.folder = modelWithFullData.folder;
} }
// Show the back-to-top button once the modal content is scrolled
const modalContent = activeModalElement.querySelector('.modal-content');
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
if (modalContent && backToTopBtn) {
modalContent.addEventListener('scroll', () => {
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
});
}
} }
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable); updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
onUpdateStatusChange: handleUpdateStatusChange, onUpdateStatusChange: handleUpdateStatusChange,
}); });
setupEditableFields(modelWithFullData.file_path, modelType); setupEditableFields(modelWithFullData.file_path, modelType);
showcaseCleanup = setupShowcaseScroll(modalId);
setupTabSwitching({ setupTabSwitching({
onTabChange: async (tab) => { onTabChange: async (tab) => {
if (tab === 'versions') { if (tab === 'versions') {
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
const customImages = modelWithFullData.civitai?.customImages || []; const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images // Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages]; const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, modelWithFullData.sha256); loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
} }
function renderLoraSpecificContent(lora, escapedWords) { function renderLoraSpecificContent(lora, escapedWords) {
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal, show: showModelModal,
toggleShowcase,
scrollToTop scrollToTop
}; };
@@ -4,9 +4,9 @@
*/ */
/** /**
* Generate video wrapper HTML * Generate video wrapper HTML. The wrapper fills its container (the gallery's
* main viewer) and the media is letterboxed inside via object-fit: contain.
* @param {Object} media - Media metadata * @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred * @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text * @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML * @param {string} metadataPanel - Metadata panel HTML
@@ -15,11 +15,11 @@
* @param {string} mediaControlsHtml - HTML for media control buttons * @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') { export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0; const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return ` return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}"> <div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? ` ${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur"> <button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
} }
/** /**
* Generate image wrapper HTML * Generate image wrapper HTML. The wrapper fills its container (the gallery's
* main viewer) and the media is letterboxed inside via object-fit: contain.
* @param {Object} media - Media metadata * @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred * @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text * @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML * @param {string} metadataPanel - Metadata panel HTML
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
* @param {string} mediaControlsHtml - HTML for media control buttons * @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') { export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0; const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return ` return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}"> <div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? ` ${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur"> <button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
+154 -168
View File
@@ -213,190 +213,170 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
} }
/** /**
* Initialize metadata panel interaction handlers * Initialize metadata panel interaction handlers: hover over the media reveals
* the panel and media controls (same as the legacy carousel). Panel-internal
* buttons and wheel isolation are bound here as well.
* @param {HTMLElement} container - Container element with media wrappers * @param {HTMLElement} container - Container element with media wrappers
*/ */
export function initMetadataPanelHandlers(container) { export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper'); const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => { mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel'); const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaControls = wrapper.querySelector('.media-controls'); const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video'); const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return; if (mediaElement) {
let isOverMetadataPanel = false;
let isOverMetadataPanel = false;
// Hovering the actual media content reveals the metadata panel and controls
// Add event listeners to the wrapper for mouse tracking wrapper.addEventListener('mousemove', (e) => {
wrapper.addEventListener('mousemove', (e) => { const rect = wrapper.getBoundingClientRect();
// Get mouse position relative to wrapper const mouseX = e.clientX - rect.left;
const rect = wrapper.getBoundingClientRect(); const mouseY = e.clientY - rect.top;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const isOverMedia = (
// Get the actual displayed dimensions of the media element mouseX >= mediaRect.left &&
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height); mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
// Check if mouse is over the actual media content mouseY <= mediaRect.bottom
const isOverMedia = ( );
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right && if (isOverMedia || isOverMetadataPanel) {
mouseY >= mediaRect.top && metadataPanel.classList.add('visible');
mouseY <= mediaRect.bottom if (mediaControls) mediaControls.classList.add('visible');
); } else {
metadataPanel.classList.remove('visible');
// Show metadata panel and controls when over media content or metadata panel itself if (mediaControls) mediaControls.classList.remove('visible');
if (isOverMedia || isOverMetadataPanel) { }
if (metadataPanel) metadataPanel.classList.add('visible'); });
if (mediaControls) mediaControls.classList.add('visible');
} else { wrapper.addEventListener('mouseleave', () => {
if (metadataPanel) metadataPanel.classList.remove('visible'); if (!isOverMetadataPanel) {
if (mediaControls) mediaControls.classList.remove('visible'); metadataPanel.classList.remove('visible');
} if (mediaControls) mediaControls.classList.remove('visible');
}); }
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => { metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true; isOverMetadataPanel = true;
metadataPanel.classList.add('visible'); metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible'); if (mediaControls) mediaControls.classList.add('visible');
}); });
metadataPanel.addEventListener('mouseleave', () => { metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false; isOverMetadataPanel = false;
// Only hide if mouse is not over the media metadataPanel.classList.remove('visible');
const rect = wrapper.getBoundingClientRect(); if (mediaControls) mediaControls.classList.remove('visible');
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
}); });
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
});
});
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
} }
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
});
});
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}); });
} }
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
bubbles: true,
detail: { shortId }
}));
// Success: remove the media wrapper from the DOM // Success: remove the media wrapper from the DOM
mediaWrapper.style.opacity = '0'; mediaWrapper.style.opacity = '0';
mediaWrapper.style.height = '0'; mediaWrapper.style.height = '0';
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
// Initialize NSFW level buttons // Initialize NSFW level buttons
initSetNsfwHandlers(container); initSetNsfwHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers // Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
// Any click handlers or other functionality can still be added here // Any click handlers or other functionality can still be added here
} }
File diff suppressed because it is too large Load Diff
@@ -45,8 +45,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
})); }));
vi.mock(SHOWCASE_MODULE, () => ({ vi.mock(SHOWCASE_MODULE, () => ({
toggleShowcase: vi.fn(),
setupShowcaseScroll: vi.fn(),
scrollToTop: vi.fn(), scrollToTop: vi.fn(),
loadExampleImages: vi.fn(), loadExampleImages: vi.fn(),
})); }));
@@ -43,8 +43,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
})); }));
vi.mock(SHOWCASE_MODULE, () => ({ vi.mock(SHOWCASE_MODULE, () => ({
toggleShowcase: vi.fn(),
setupShowcaseScroll: vi.fn(),
scrollToTop: vi.fn(), scrollToTop: vi.fn(),
loadExampleImages: vi.fn(), loadExampleImages: vi.fn(),
})); }));
@@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
const { SHOWCASE_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
}));
vi.mock(MEDIA_UTILS_MODULE, () => ({
initLazyLoading: vi.fn(),
initNsfwBlurHandlers: vi.fn(),
initMetadataPanelHandlers: vi.fn(),
initMediaControlHandlers: vi.fn(),
positionAllMediaControls: vi.fn(),
}));
vi.mock(MEDIA_VIEWER_MODULE, () => ({
openMediaViewer: vi.fn(),
}));
const PREVIEW_URL = '/loras_static/preview/abc.png';
const IMAGES = [
{ url: 'https://image.civitai.com/abc/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
{ url: 'https://image.civitai.com/abc/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
{ url: 'https://image.civitai.com/abc/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
];
describe('Showcase gallery', () => {
let state;
beforeEach(async () => {
Element.prototype.scrollIntoView = vi.fn();
const stateModule = await import('../../../static/js/state/index.js');
state = stateModule.state;
state.settings.show_only_sfw = false;
state.settings.blur_mature_content = false;
state.global.settings.example_images_path = '/tmp/examples';
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
it('starts collapsed: slim indicator bar only, no remote examples rendered', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.showcase-gallery')).toBeTruthy();
expect(host.querySelector('.gallery-indicator-bar')).toBeTruthy();
// Collapsed bar carries the count and the local preview thumbnail
expect(host.querySelector('#galleryShowBtn')?.textContent).toContain('3');
expect(host.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
// No thumbnails / media wrappers → no remote fetches until expanded
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(0);
expect(host.querySelector('.media-wrapper')).toBeNull();
// Import zone exists but stays collapsed
const zone = host.querySelector('.gallery-import-zone');
expect(zone?.classList.contains('hidden')).toBe(true);
});
it('expanded render shows toolbar, main viewer, thumbnails and nav controls', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.gallery-indicator-bar')).toBeNull();
expect(host.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
expect(host.querySelector('.main-media-container .media-wrapper')).toBeTruthy();
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(3);
expect(host.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
expect(host.querySelector('#galleryPrevBtn')).toBeTruthy();
expect(host.querySelector('#galleryNextBtn')).toBeTruthy();
});
it('show/hide button toggles between indicator bar and gallery', async () => {
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
// Expand
document.querySelector('#galleryShowBtn').click();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(3);
expect(document.querySelector('.gallery-indicator-bar')).toBeNull();
// Collapse back to the indicator bar
document.querySelector('#galleryShowBtn').click();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
expect(document.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
});
it('omits the import zone when the example images path is not configured', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.global.settings.example_images_path = '';
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
expect(host.querySelector('.gallery-import-zone')).toBeNull();
});
it('filters NSFW examples and reports the hidden count', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.settings.show_only_sfw = true;
const images = [
IMAGES[0],
{ url: 'https://image.civitai.com/abc/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
];
const html = renderShowcaseContent(images, [], '', true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(1);
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
// Only one example left → no prev/next controls
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
});
it('renders the import interface when there are no examples', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent([], [], PREVIEW_URL);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.example-import-area.empty')).toBeTruthy();
expect(host.querySelector('#selectExampleFilesBtn')).toBeTruthy();
});
it('renders the setup guidance when the path is missing and there are no examples', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.global.settings.example_images_path = '';
const html = renderShowcaseContent([], []);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.import-container--needs-setup')).toBeTruthy();
expect(host.querySelector('#openExampleSettingsBtn')).toBeTruthy();
});
it('switches the main display, position and active thumbnail when expanded', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
updateMainDisplay(2);
const activeThumb = document.querySelector('.gallery-thumb.active');
expect(activeThumb?.dataset.index).toBe('2');
expect(document.querySelector('#galleryPosition')?.textContent).toBe('3 / 3');
const mainWrapper = document.querySelector('#mainMediaContainer .media-wrapper');
expect(mainWrapper).toBeTruthy();
// The third example is a video
expect(mainWrapper.querySelector('video')).toBeTruthy();
// Wraps around past the end
updateMainDisplay(3);
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
});
it('fits the main viewer to the active media aspect ratio', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
// First image is portrait 512x768 → aspect 0.667
const container = document.getElementById('mainMediaContainer');
expect(container.style.getPropertyValue('--media-aspect')).toBe(String(512 / 768));
// Second image is landscape 768x512 → aspect 1.5
updateMainDisplay(1);
expect(container.style.getPropertyValue('--media-aspect')).toBe('1.5');
});
it('ignores main-display updates while collapsed', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
updateMainDisplay(1);
// Still collapsed: indicator bar untouched, no gallery rendered
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
});
});
@@ -1,72 +0,0 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
const { SHOWCASE_MODULE } = vi.hoisted(() => ({
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
}));
describe('Showcase listener metrics', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="modelModal">
<div class="modal-content">
<div class="showcase-section">
<div class="carousel collapsed">
<div class="scroll-indicator"></div>
</div>
<button class="back-to-top"></button>
</div>
</div>
</div>
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
it('tracks wheel/mutation/back-to-top listeners and resets after cleanup', async () => {
const {
setupShowcaseScroll,
resetShowcaseListenerMetrics,
showcaseListenerMetrics,
} = await import(SHOWCASE_MODULE);
resetShowcaseListenerMetrics();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
const cleanup = setupShowcaseScroll('modelModal');
expect(showcaseListenerMetrics.wheelListeners).toBe(1);
expect(showcaseListenerMetrics.mutationObservers).toBe(1);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(1);
cleanup();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
});
it('remains stable after repeated setup/cleanup cycles', async () => {
const {
setupShowcaseScroll,
resetShowcaseListenerMetrics,
showcaseListenerMetrics,
} = await import(SHOWCASE_MODULE);
resetShowcaseListenerMetrics();
const cleanupA = setupShowcaseScroll('modelModal');
cleanupA();
const cleanupB = setupShowcaseScroll('modelModal');
cleanupB();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
});
});