Files
ComfyUI-Lora-Manager/static/js/utils/uiHelpers.js

961 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NSFW_LEVELS } from './constants.js';
/**
* Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard
* @param {string} successMessage - Optional success message to show in toast
* @returns {Promise<boolean>} - Promise that resolves to true if copy was successful
*/
export async function copyToClipboard(text, successMessage = 'Copied to clipboard') {
try {
// Modern clipboard API
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-99999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
if (successMessage) {
showToast(successMessage, 'success');
}
return true;
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
return false;
}
}
export function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// Get or create toast container
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.append(toastContainer);
}
toastContainer.append(toast);
// Calculate vertical position for stacked toasts
const existingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
const toastIndex = existingToasts.indexOf(toast);
const topOffset = 20; // Base offset from top
const spacing = 10; // Space between toasts
// Set position based on existing toasts
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
requestAnimationFrame(() => {
toast.classList.add('show');
// Set timeout based on type
let timeout = 2000; // Default (info)
if (type === 'warning' || type === 'error') {
timeout = 5000;
}
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
// Reposition remaining toasts
if (toastContainer) {
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
remainingToasts.forEach((t, index) => {
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
});
// Remove container if empty
if (remainingToasts.length === 0) {
toastContainer.remove();
}
}
});
}, timeout);
});
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
if (folderTag) {
folderTag.classList.add('active');
filterByFolder(activeFolder);
}
}
export function initTheme() {
const savedTheme = getStorageItem('theme') || 'auto';
applyTheme(savedTheme);
// Update theme when system preference changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto';
let newTheme;
if (currentTheme === 'dark') {
newTheme = 'light';
} else {
newTheme = 'dark';
}
setStorageItem('theme', newTheme);
applyTheme(newTheme);
// Force a repaint to ensure theme changes are applied immediately
document.body.style.display = 'none';
document.body.offsetHeight; // Trigger a reflow
document.body.style.display = '';
return newTheme;
}
// Add a new helper function to apply the theme
function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement;
// Remove any existing theme attributes
htmlElement.removeAttribute('data-theme');
// Apply the appropriate theme
if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark';
} else {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
}
export function toggleFolder(tag) {
const tagElement = (tag instanceof HTMLElement) ? tag : this;
const folder = tagElement.dataset.folder;
const wasActive = tagElement.classList.contains('active');
document.querySelectorAll('.folder-tags .tag').forEach(t => {
t.classList.remove('active');
});
if (!wasActive) {
tagElement.classList.add('active');
state.activeFolder = folder;
} else {
state.activeFolder = null;
}
resetAndReload();
}
function filterByFolder(folderPath) {
document.querySelectorAll('.lora-card').forEach(card => {
card.style.display = card.dataset.folder === folderPath ? '' : 'none';
});
}
export function openCivitai(modelName) {
// 从卡片的data-meta属性中获取civitai ID
const loraCard = document.querySelector(`.lora-card[data-name="${modelName}"]`);
if (!loraCard) return;
const metaData = JSON.parse(loraCard.dataset.meta);
const civitaiId = metaData.modelId; // 使用modelId作为civitai模型ID
const versionId = metaData.id; // 使用id作为版本ID
// 构建URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// 如果没有ID尝试使用名称搜索
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
}
/**
* Dynamically positions the search options panel and filter panel
* based on the current layout and folder tags container height
*/
export function updatePanelPositions() {
const searchOptionsPanel = document.getElementById('searchOptionsPanel');
const filterPanel = document.getElementById('filterPanel');
if (!searchOptionsPanel && !filterPanel) return;
// Get the header element
const header = document.querySelector('.app-header');
if (!header) return;
// Calculate the position based on the bottom of the header
const headerRect = header.getBoundingClientRect();
const topPosition = headerRect.bottom + 5; // Add 5px padding
// Set the positions
if (searchOptionsPanel) {
searchOptionsPanel.style.top = `${topPosition}px`;
}
if (filterPanel) {
filterPanel.style.top = `${topPosition}px`;
}
// Adjust panel horizontal position based on the search container
const searchContainer = document.querySelector('.header-search');
if (searchContainer) {
const searchRect = searchContainer.getBoundingClientRect();
// Position the search options panel aligned with the search container
if (searchOptionsPanel) {
searchOptionsPanel.style.right = `${window.innerWidth - searchRect.right}px`;
}
// Position the filter panel aligned with the filter button
if (filterPanel) {
const filterButton = document.getElementById('filterButton');
if (filterButton) {
const filterRect = filterButton.getBoundingClientRect();
filterPanel.style.right = `${window.innerWidth - filterRect.right}px`;
}
}
}
}
// Update the toggleFolderTags function
export function toggleFolderTags() {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.toggle('collapsed');
if (folderTags.classList.contains('collapsed')) {
// Change icon to indicate folders are hidden
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
setStorageItem('folderTagsCollapsed', 'true');
} else {
// Change icon to indicate folders are visible
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
setStorageItem('folderTagsCollapsed', 'false');
}
// Update panel positions after toggling
// Use a small delay to ensure the DOM has updated
setTimeout(() => {
updatePanelPositions();
}, 50);
}
}
// Add this to your existing initialization code
export function initFolderTagsVisibility() {
const isCollapsed = getStorageItem('folderTagsCollapsed');
if (isCollapsed) {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
}
} else {
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
}
}
}
export function initBackToTop() {
const button = document.getElementById('backToTopBtn');
if (!button) return;
// Get the scrollable container
const scrollContainer = document.querySelector('.page-content');
// Show/hide button based on scroll position
const toggleBackToTop = () => {
const scrollThreshold = window.innerHeight * 0.3;
if (scrollContainer.scrollTop > scrollThreshold) {
button.classList.add('visible');
} else {
button.classList.remove('visible');
}
};
// Smooth scroll to top
button.addEventListener('click', () => {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
});
// Listen for scroll events on the scrollable container
scrollContainer.addEventListener('scroll', toggleBackToTop);
// Initial check
toggleBackToTop();
}
export function getNSFWLevelName(level) {
if (level === 0) return 'Unknown';
if (level >= 32) return 'Blocked';
if (level >= 16) return 'XXX';
if (level >= 8) return 'X';
if (level >= 4) return 'R';
if (level >= 2) return 'PG13';
if (level >= 1) return 'PG';
return 'Unknown';
}
/**
* Sends LoRA syntax to the active ComfyUI workflow
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
* @returns {Promise<boolean>} - Whether the operation was successful
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
try {
let loraNodes = [];
let isDesktopMode = false;
// Get the current workflow from localStorage
const workflowData = localStorage.getItem('workflow');
if (workflowData) {
// Web browser mode - extract node IDs from workflow
const workflow = JSON.parse(workflowData);
// Find all Lora Loader (LoraManager) nodes
if (workflow.nodes && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.type === "Lora Loader (LoraManager)") {
loraNodes.push(node.id);
}
}
}
if (loraNodes.length === 0) {
showToast('No Lora Loader nodes found in the workflow', 'warning');
return false;
}
} else {
// ComfyUI Desktop mode - don't specify node IDs and let backend handle it
isDesktopMode = true;
}
// Call the backend API to update the lora code
const response = await fetch('/api/update-lora-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
node_ids: isDesktopMode ? undefined : loraNodes,
lora_code: loraSyntax,
mode: replaceMode ? 'replace' : 'append'
})
});
const result = await response.json();
if (result.success) {
// Use different toast messages based on syntax type
if (syntaxType === 'recipe') {
showToast(`Recipe ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
} else {
showToast(`LoRA ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
}
return true;
} else {
showToast(result.error || `Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
} catch (error) {
console.error('Failed to send to workflow:', error);
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
}
/**
* Opens the example images folder for a specific model
* @param {string} modelHash - The SHA256 hash of the model
*/
export async function openExampleImagesFolder(modelHash) {
try {
const response = await fetch('/api/open-example-images-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hash: modelHash
})
});
const result = await response.json();
if (result.success) {
showToast('Opening example images folder', 'success');
return true;
} else {
showToast(result.error || 'Failed to open example images folder', 'error');
return false;
}
} catch (error) {
console.error('Failed to open example images folder:', error);
showToast('Failed to open example images folder', 'error');
return false;
}
}
/**
* Gets local URLs for example images with primary and fallback options
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {Object} - Object with primary and fallback URLs
*/
export function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return { primary: null, fallback: null };
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension with no fallback
if (remoteExt === 'mp4') {
const videoUrl = `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
return { primary: videoUrl, fallback: null };
}
// For images, prepare both possible formats
const basePath = `/example_images_static/${modelHash}/image_${index + 1}`;
const webpUrl = `${basePath}.webp`;
const originalExtUrl = remoteExt ? `${basePath}.${remoteExt}` : `${basePath}.jpg`;
// Check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Return primary and fallback URLs based on current settings
return {
primary: optimizeImages ? webpUrl : originalExtUrl,
fallback: optimizeImages ? originalExtUrl : webpUrl
};
}
/**
* Try to load local image first, fall back to remote if local fails
* @param {HTMLImageElement} imgElement - The image element to update
* @param {Object} urls - Object with local URLs {primary, fallback} and remote URL
*/
export function tryLocalImageOrFallbackToRemote(imgElement, urls) {
const { primary: localUrl, fallback: fallbackUrl } = urls.local || {};
const remoteUrl = urls.remote;
// If no local options, use remote directly
if (!localUrl) {
imgElement.src = remoteUrl;
return;
}
// Try primary local URL
const testImg = new Image();
testImg.onload = () => {
// Primary local image loaded successfully
imgElement.src = localUrl;
};
testImg.onerror = () => {
// Try fallback URL if available
if (fallbackUrl) {
const fallbackImg = new Image();
fallbackImg.onload = () => {
imgElement.src = fallbackUrl;
};
fallbackImg.onerror = () => {
// Both local options failed, use remote
imgElement.src = remoteUrl;
};
fallbackImg.src = fallbackUrl;
} else {
// No fallback, use remote
imgElement.src = remoteUrl;
}
};
testImg.src = localUrl;
}
/**
* Try to load local video first, fall back to remote if local fails
* @param {HTMLVideoElement} videoElement - The video element to update
* @param {Object} urls - Object with local URLs {primary} and remote URL
*/
export function tryLocalVideoOrFallbackToRemote(videoElement, urls) {
const { primary: localUrl } = urls.local || {};
const remoteUrl = urls.remote;
// Only try local if we have a local path
if (localUrl) {
// Try to fetch local file headers to see if it exists
fetch(localUrl, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localUrl;
const source = videoElement.querySelector('source');
if (source) source.src = localUrl;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
}
}
/**
* Initialize lazy loading for images and videos in a container
* @param {HTMLElement} container - The container with lazy-loadable elements
*/
export function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
// Get URLs from data attributes
const localUrls = {
primary: element.dataset.localSrc || null,
fallback: element.dataset.localFallbackSrc || null
};
const remoteUrl = element.dataset.remoteSrc;
const urls = {
local: localUrls,
remote: remoteUrl
};
// Check if element is a video or image
if (element.tagName.toLowerCase() === 'video') {
tryLocalVideoOrFallbackToRemote(element, urls);
} else {
tryLocalImageOrFallbackToRemote(element, urls);
}
element.classList.remove('lazy');
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
export function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* Initialize metadata panel interaction handlers
* @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');
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !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 when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.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');
}
});
// 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('Copy failed', 'error');
}
});
});
// 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 });
});
}
/**
* Initialize NSFW content blur toggle handlers
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
toggleButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
const isBlurred = media.classList.toggle('blurred');
const icon = btn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
});
// Handle "Show" buttons in overlays
const showButtons = container.querySelectorAll('.show-content-btn');
showButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
media.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel);
// Initialize NSFW content blur toggle handlers
initNsfwBlurHandlers(carousel);
// Initialize metadata panel interaction handlers
initMetadataPanelHandlers(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
}
/**
* Set up showcase scroll functionality
* @param {string} modalId - ID of the modal element
*/
export function setupShowcaseScroll(modalId) {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.querySelector('.modal-content'));
}
}
}
});
// Start observing the document body for changes
observer.observe(document.body, { childList: true, subtree: true });
// Also try to set up the button immediately in case the modal is already open
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up back-to-top button
* @param {HTMLElement} modalContent - Modal content element
*/
export function setupBackToTopButton(modalContent) {
// Remove any existing scroll listeners to avoid duplicates
modalContent.onscroll = null;
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
}
/**
* Scroll to top of modal content
* @param {HTMLElement} button - Back to top button element
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
/**
* Get example image files for a specific model from the backend
* @param {string} modelHash - The model's hash
* @returns {Promise<Array>} Array of file objects with path and metadata
*/
export async function getExampleImageFiles(modelHash) {
try {
const response = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const result = await response.json();
if (result.success) {
return result.files;
} else {
console.error('Failed to get example image files:', result.error);
return [];
}
} catch (error) {
console.error('Error fetching example image files:', error);
return [];
}
}