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
+21 -23
View File
@@ -1,7 +1,6 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.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) {
// Create model metadata object
const modelMeta = {
sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath,
model_name: card.dataset.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)
const modelMeta = {
sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
// Show the model modal
await showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible
// Reveal the import entry once the modal content has rendered
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) {
const showcaseTab = document.getElementById('showcase-tab');
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' });
}
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 500);
};
+9 -11
View File
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
updateCardUpdateAvailability(hasUpdate);
}
let showcaseCleanup;
const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId);
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
if (showcaseCleanup) {
showcaseCleanup();
showcaseCleanup = null;
}
cleanupNavigationShortcuts();
};
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
if (modelType === 'embeddings' && 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);
const versionsTabController = initVersionsTab({
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
onUpdateStatusChange: handleUpdateStatusChange,
});
setupEditableFields(modelWithFullData.file_path, modelType);
showcaseCleanup = setupShowcaseScroll(modalId);
setupTabSwitching({
onTabChange: async (tab) => {
if (tab === 'versions') {
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, modelWithFullData.sha256);
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
}
function renderLoraSpecificContent(lora, escapedWords) {
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
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 {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML
@@ -15,11 +15,11 @@
* @param {string} mediaControlsHtml - HTML for media control buttons
* @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;
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 ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<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 {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @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
* @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;
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 ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<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
*/
export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) 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) {
if (mediaElement) {
let isOverMetadataPanel = false;
// Hovering the actual media content reveals the metadata panel and controls
wrapper.addEventListener('mousemove', (e) => {
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
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');
}
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();
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
mediaWrapper.style.opacity = '0';
mediaWrapper.style.height = '0';
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
// Initialize NSFW level buttons
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
}
File diff suppressed because it is too large Load Diff