From 8b59fb6adc4439598d907347d0810e160a9be491 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Tue, 3 Jun 2025 16:06:54 +0800 Subject: [PATCH] Refactor ShowcaseView and uiHelpers for improved image/video handling - Moved getLocalExampleImageUrl function to uiHelpers.js for better modularity. - Updated ShowcaseView.js to utilize the new structure for local and fallback URLs. - Enhanced lazy loading functions to support both primary and fallback URLs for images and videos. - Simplified metadata panel generation in ShowcaseView.js. - Improved showcase toggle functionality and added initialization for lazy loading and metadata handlers. --- .../checkpointModal/ShowcaseView.js | 479 +------------ .../js/components/loraModal/ShowcaseView.js | 648 +++--------------- static/js/utils/uiHelpers.js | 496 ++++++++++++++ 3 files changed, 630 insertions(+), 993 deletions(-) diff --git a/static/js/components/checkpointModal/ShowcaseView.js b/static/js/components/checkpointModal/ShowcaseView.js index 86f539d1..4dbed71d 100644 --- a/static/js/components/checkpointModal/ShowcaseView.js +++ b/static/js/components/checkpointModal/ShowcaseView.js @@ -2,37 +2,20 @@ * ShowcaseView.js * Handles showcase content (images, videos) display for checkpoint modal */ -import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; +import { + showToast, + copyToClipboard, + getLocalExampleImageUrl, + initLazyLoading, + initNsfwBlurHandlers, + initMetadataPanelHandlers, + toggleShowcase, + setupShowcaseScroll, + scrollToTop +} from '../../utils/uiHelpers.js'; import { state } from '../../state/index.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; -/** - * Get the local URL for an example image if available - * @param {Object} img - Image object - * @param {number} index - Image index - * @param {string} modelHash - Model hash - * @returns {string|null} - Local URL or null if not available - */ -function getLocalExampleImageUrl(img, index, modelHash) { - if (!modelHash) return null; - - // Get remote extension - const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase(); - - // If it's a video (mp4), use that extension - if (remoteExt === 'mp4') { - return `/example_images_static/${modelHash}/image_${index + 1}.mp4`; - } - - // For images, check if optimization is enabled (defaults to true) - const optimizeImages = state.settings.optimizeExampleImages !== false; - - // Use .webp for images if optimization enabled, otherwise use original extension - const extension = optimizeImages ? 'webp' : remoteExt; - - return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`; -} - /** * Render showcase content * @param {Array} images - Array of images/videos to show @@ -82,9 +65,9 @@ export function renderShowcaseContent(images, modelHash) { ${hiddenNotification} @@ -96,11 +79,8 @@ export function renderShowcaseContent(images, modelHash) { * @param {Object} media - Media object with image or video data * @returns {string} HTML content */ -function generateMediaWrapper(media, localUrl = null) { - // Calculate appropriate aspect ratio: - // 1. Keep original aspect ratio - // 2. Limit maximum height to 60% of viewport height - // 3. Ensure minimum height is 40% of container width +function generateMediaWrapper(media, urls) { + // Calculate appropriate aspect ratio const aspectRatio = (media.height / media.width) * 100; const containerWidth = 800; // modal content maximum width const minHeightPercent = 40; @@ -149,10 +129,10 @@ function generateMediaWrapper(media, localUrl = null) { // Check if this is a video or image if (media.type === 'video') { - return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); + return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls); } - return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); + return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls); } /** @@ -225,7 +205,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si /** * Generate video wrapper HTML */ -function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { +function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) { return `
${shouldBlur ? ` @@ -235,10 +215,10 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada ` : ''} ${shouldBlur ? ` @@ -257,7 +237,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada /** * Generate image wrapper HTML */ -function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { +function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) { return `
${shouldBlur ? ` @@ -265,7 +245,8 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada ` : ''} - Preview { - // 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', () => { - // Only hide panel when mouse leaves the wrapper and not over the metadata panel - 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 }); - }); -} - -/** - * 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 - */ -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 blur toggle handlers - */ -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'; - } - }); - }); -} - -/** - * Initialize lazy loading for images and videos - */ -function initLazyLoading(container) { - const lazyElements = container.querySelectorAll('.lazy'); - - const lazyLoad = (element) => { - const localSrc = element.dataset.localSrc; - const remoteSrc = element.dataset.remoteSrc; - - // Check if element is an image or video - if (element.tagName.toLowerCase() === 'video') { - // Try local first, then remote - tryLocalOrFallbackToRemote(element, localSrc, remoteSrc); - } else { - // For images, we'll use an Image object to test if local file exists - tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc); - } - - element.classList.remove('lazy'); - }; - - // Try to load local image first, fall back to remote if local fails - const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => { - // Only try local if we have a local path - if (localSrc) { - const testImg = new Image(); - testImg.onload = () => { - // Local image loaded successfully - imgElement.src = localSrc; - }; - testImg.onerror = () => { - // Local image failed, use remote - imgElement.src = remoteSrc; - }; - // Start loading test image - testImg.src = localSrc; - } else { - // No local path, use remote directly - imgElement.src = remoteSrc; - } - }; - - // Try to load local video first, fall back to remote if local fails - const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => { - // Only try local if we have a local path - if (localSrc) { - // Try to fetch local file headers to see if it exists - fetch(localSrc, { method: 'HEAD' }) - .then(response => { - if (response.ok) { - // Local video exists, use it - videoElement.src = localSrc; - videoElement.querySelector('source').src = localSrc; - } else { - // Local video doesn't exist, use remote - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - } - videoElement.load(); - }) - .catch(() => { - // Error fetching, use remote - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - videoElement.load(); - }); - } else { - // No local path, use remote directly - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - videoElement.load(); - } - }; - - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - lazyLoad(entry.target); - observer.unobserve(entry.target); - } - }); - }); - - lazyElements.forEach(element => observer.observe(element)); -} - -/** - * Set up showcase scroll functionality - */ -export function setupShowcaseScroll() { - // Listen for wheel events - document.addEventListener('wheel', (event) => { - const modalContent = document.querySelector('#checkpointModal .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 checkpointModal = document.getElementById('checkpointModal'); - if (checkpointModal && checkpointModal.querySelector('.modal-content')) { - setupBackToTopButton(checkpointModal.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('#checkpointModal .modal-content'); - if (modalContent) { - setupBackToTopButton(modalContent); - } -} - -/** - * Set up back-to-top button - */ -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 - */ -export function scrollToTop(button) { - const modalContent = button.closest('.modal-content'); - if (modalContent) { - modalContent.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } -} +// Initialize the showcase scroll when this module is imported +document.addEventListener('DOMContentLoaded', () => { + setupShowcaseScroll('checkpointModal'); +}); diff --git a/static/js/components/loraModal/ShowcaseView.js b/static/js/components/loraModal/ShowcaseView.js index 18d61a8a..6c8a7db5 100644 --- a/static/js/components/loraModal/ShowcaseView.js +++ b/static/js/components/loraModal/ShowcaseView.js @@ -2,37 +2,20 @@ * ShowcaseView.js * 处理LoRA模型展示内容(图片、视频)的功能模块 */ -import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; +import { + showToast, + copyToClipboard, + getLocalExampleImageUrl, + initLazyLoading, + initNsfwBlurHandlers, + initMetadataPanelHandlers, + toggleShowcase, + setupShowcaseScroll, + scrollToTop +} from '../../utils/uiHelpers.js'; import { state } from '../../state/index.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; -/** - * Get the local URL for an example image if available - * @param {Object} img - Image object - * @param {number} index - Image index - * @param {string} modelHash - Model hash - * @returns {string|null} - Local URL or null if not available - */ -function getLocalExampleImageUrl(img, index, modelHash) { - if (!modelHash) return null; - - // Get remote extension - const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase(); - - // If it's a video (mp4), use that extension - if (remoteExt === 'mp4') { - return `/example_images_static/${modelHash}/image_${index + 1}.mp4`; - } - - // For images, check if optimization is enabled (defaults to true) - const optimizeImages = state.settings.optimizeExampleImages !== false; - - // Use .webp for images if optimization enabled, otherwise use original extension - const extension = optimizeImages ? 'webp' : remoteExt; - - return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`; -} - /** * 渲染展示内容 * @param {Array} images - 要展示的图片/视频数组 @@ -82,21 +65,13 @@ export function renderShowcaseContent(images, modelHash) { ${hiddenNotification}
`; } +/** + * Generate metadata panel HTML + */ +function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) { + // Create unique IDs for prompt copying + const promptIndex = Math.random().toString(36).substring(2, 15); + const negPromptIndex = Math.random().toString(36).substring(2, 15); + + let content = '
'; + return content; +} + /** * 生成视频包装HTML */ -function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) { +function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) { return `
${shouldBlur ? ` @@ -232,10 +203,10 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata ` : ''} ${shouldBlur ? ` @@ -254,7 +225,7 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata /** * 生成图片包装HTML */ -function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) { +function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) { return `
${shouldBlur ? ` @@ -262,8 +233,9 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata ` : ''} - Preview { - carouselContainer.style.height = ''; - }, 300); - } - } -} +// Use the shared setupShowcaseScroll function with the correct modal ID +export { setupShowcaseScroll, scrollToTop, toggleShowcase }; -/** - * 初始化元数据面板交互处理 - */ -function initMetadataPanelHandlers(container) { - // Find all media wrappers - 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 - if (isOverMedia || isOverMetadataPanel) { - metadataPanel.classList.add('visible'); - } else { - metadataPanel.classList.remove('visible'); - } - }); - - wrapper.addEventListener('mouseleave', () => { - // Only hide panel when mouse leaves the wrapper and not over the metadata panel - 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 the metadata panel from bubbling - metadataPanel.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - // Handle copy prompt button clicks - 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(); // Prevent bubbling - - if (!promptElement) return; - - try { - await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard'); - } catch (err) { - console.error('Copy failed:', err); - showToast('Copy failed', 'error'); - } - }); - }); - - // Prevent scrolling in the metadata panel from scrolling the whole modal - 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 }); - }); -} - -/** - * 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 - */ -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 - }; -} - -/** - * 初始化模糊切换处理 - */ -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'; - } - }); - }); -} - -/** - * 初始化延迟加载 - */ -function initLazyLoading(container) { - const lazyElements = container.querySelectorAll('.lazy'); - - const lazyLoad = (element) => { - const localSrc = element.dataset.localSrc; - const remoteSrc = element.dataset.remoteSrc; - - // Check if element is an image or video - if (element.tagName.toLowerCase() === 'video') { - // Try local first, then remote - tryLocalOrFallbackToRemote(element, localSrc, remoteSrc); - } else { - // For images, we'll use an Image object to test if local file exists - tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc); - } - - element.classList.remove('lazy'); - }; - - // Try to load local image first, fall back to remote if local fails - const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => { - // Only try local if we have a local path - if (localSrc) { - const testImg = new Image(); - testImg.onload = () => { - // Local image loaded successfully - imgElement.src = localSrc; - }; - testImg.onerror = () => { - // Local image failed, use remote - imgElement.src = remoteSrc; - }; - // Start loading test image - testImg.src = localSrc; - } else { - // No local path, use remote directly - imgElement.src = remoteSrc; - } - }; - - // Try to load local video first, fall back to remote if local fails - const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => { - // Only try local if we have a local path - if (localSrc) { - // Try to fetch local file headers to see if it exists - fetch(localSrc, { method: 'HEAD' }) - .then(response => { - if (response.ok) { - // Local video exists, use it - videoElement.src = localSrc; - videoElement.querySelector('source').src = localSrc; - } else { - // Local video doesn't exist, use remote - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - } - videoElement.load(); - }) - .catch(() => { - // Error fetching, use remote - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - videoElement.load(); - }); - } else { - // No local path, use remote directly - videoElement.src = remoteSrc; - videoElement.querySelector('source').src = remoteSrc; - videoElement.load(); - } - }; - - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - lazyLoad(entry.target); - observer.unobserve(entry.target); - } - }); - }); - - lazyElements.forEach(element => observer.observe(element)); -} - -/** - * 设置展示区域的滚动处理 - */ -export function setupShowcaseScroll() { - // Add event listener to document for wheel events - document.addEventListener('wheel', (event) => { - // Find the active modal content - const modalContent = document.querySelector('#loraModal .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 instead of deprecated DOMNodeInserted - const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.type === 'childList' && mutation.addedNodes.length) { - // Check if loraModal content was added - const loraModal = document.getElementById('loraModal'); - if (loraModal && loraModal.querySelector('.modal-content')) { - setupBackToTopButton(loraModal.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('#loraModal .modal-content'); - if (modalContent) { - setupBackToTopButton(modalContent); - } -} - -/** - * 设置返回顶部按钮 - */ -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')); -} - -/** - * 滚动到顶部 - */ -export function scrollToTop(button) { - const modalContent = button.closest('.modal-content'); - if (modalContent) { - modalContent.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } -} +// Initialize the showcase scroll when this module is imported +document.addEventListener('DOMContentLoaded', () => { + setupShowcaseScroll('loraModal'); +}); diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index b75a0e1d..6533ff0b 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -1,6 +1,7 @@ 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 @@ -440,4 +441,499 @@ export async function openExampleImagesFolder(modelHash) { 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' + }); + } } \ No newline at end of file