feat(ui): add optional vertical list layout for model modal showcase

Bring back the pre-v1.2.2 classic vertical list for example images as a
persisted showcase_layout setting (gallery stays the default), switchable
from both a Settings select and an in-modal segmented toggle.

- Revive the vertical list renderer (adapted from 4a6042d0) as
  VerticalListView.js, reusing the shared MediaUtils/MetadataPanel
  infrastructure; legacy CSS scoped under .showcase-vertical
- Dispatch showcase rendering on state.settings.showcase_layout;
  collapsed bar, empty/filtered states and import flow stay shared
- Keep the back-to-top button available in vertical mode (the
  showcase-expanded class that hides it now only applies to the gallery,
  whose thumbnail strip occupies that corner)
- Persist the setting via DEFAULT_SETTINGS + DEFAULT_SETTINGS_BASE and a
  new select under Settings > Layout Settings
- i18n: 6 new keys translated in all 9 locales; terminology recorded in
  docs/i18n-translation-guidelines.md

Closes #1136
This commit is contained in:
Will Miao
2026-10-05 09:38:48 +08:00
parent f94c6b7497
commit 9dacaf06f7
20 changed files with 840 additions and 172 deletions
@@ -2,6 +2,11 @@
* MediaRenderers.js
* HTML generators for media items (images/videos) in the showcase
*/
import { state } from '../../../state/index.js';
import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { getDisplayUrl, getShowcaseUrl } from '../../../utils/civitaiUtils.js';
/**
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
@@ -13,13 +18,16 @@
* @param {string} localUrl - Local file URL
* @param {string} remoteUrl - Remote file URL
* @param {string} mediaControlsHtml - HTML for media control buttons
* @param {number|null} heightPercent - Vertical-list aspect ratio (padding-bottom
* percent) sizing the wrapper before the media loads; null in gallery mode
* @returns {string} HTML content
*/
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '', heightPercent = null) {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
const wrapperStyle = heightPercent !== null ? ` style="padding-bottom: ${heightPercent}%"` : '';
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} 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>
@@ -57,27 +65,30 @@ export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel,
* @param {string} localUrl - Local file URL
* @param {string} remoteUrl - Remote file URL
* @param {string} mediaControlsHtml - HTML for media control buttons
* @param {number|null} heightPercent - Vertical-list aspect ratio (padding-bottom
* percent) sizing the wrapper before the media loads; null in gallery mode
* @returns {string} HTML content
*/
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '', heightPercent = null) {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
const wrapperStyle = heightPercent !== null ? ` style="padding-bottom: ${heightPercent}%"` : '';
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} 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>
</button>
` : ''}
${mediaControlsHtml}
<img data-local-src="${localUrl || ''}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}"
alt="Preview"
alt="Preview"
width="${media.width}"
height="${media.height}"
fetchpriority="high"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${heightPercent === null ? 'fetchpriority="high"' : ''}
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
@@ -89,4 +100,162 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
${metadataPanel}
</div>
`;
}
}
/**
* Find the matching local file for an image
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
export function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (typeof img.id === 'string' && img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render a single showcase media item (image or video) with its metadata
* panel and media controls. Shared by the gallery's main viewer and the
* vertical list layout.
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @param {Object} options - Rendering options
* @param {number|null} options.heightPercent - Vertical-list aspect ratio
* (padding-bottom percent) for the wrapper; null sizes the wrapper from its
* container (gallery main viewer)
* @param {boolean} options.fullQuality - True to use the full-quality showcase
* URL (vertical list), false for the display-optimized URL (gallery viewer)
* @returns {string} HTML for the media item
*/
export function renderShowcaseMediaItem(img, index, exampleFiles, { heightPercent = null, fullQuality = false } = {}) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
// Get original remote URL
const originalRemoteUrl = img.url || '';
// Determine media type for optimization
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Gallery main viewer: CivitAI URLs capped at width=2400 (the full-size
// media viewer uses getShowcaseUrl separately). Vertical list: full
// quality, matching the legacy behavior.
const remoteUrl = fullQuality
? getShowcaseUrl(originalRemoteUrl, mediaType)
: getDisplayUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
// Extract CivitAI image ID from CDN URL for import status check
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = translate('modals.model.showcase.nsfwR', {}, 'R-rated Content');
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
const clipSkip = meta.clip_skip || meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Determine if this is a custom image (has id property)
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
// Create the media control buttons HTML
const mediaControlsHtml = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
${hasGenMeta ? `
<button class="media-control-btn create-recipe-btn"
title="Create As Recipe"
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-img-id="${img.id || ''}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
` : ''}
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
data-media-id="${img.id || ''}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'aria-disabled="true"' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>
</div>
`;
// Generate the appropriate wrapper based on media type
if (isVideo) {
return generateVideoWrapper(
img, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml, heightPercent
);
}
return generateImageWrapper(
img, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml, heightPercent
);
}
@@ -4,9 +4,11 @@
*
* The showcase starts collapsed as a slim indicator bar ("Show N examples"),
* so opening the modal never triggers remote image fetches. Expanding reveals
* a gallery: a single main viewer with prev/next controls, a horizontal
* thumbnail strip for overview/random access, and an always-visible import
* entry — no scrolling through a vertical stack of full-width examples.
* the layout chosen by the `showcase_layout` setting: either a gallery (a
* single main viewer with prev/next controls, a horizontal thumbnail strip
* for overview/random access, and an always-visible import entry) or the
* classic vertical list (every example stacked full-width, see
* VerticalListView.js). A segmented toggle in the toolbar switches layouts.
*/
import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
@@ -20,8 +22,8 @@ import {
initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js';
import { renderVerticalList, initVerticalList } from './VerticalListView.js';
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js';
@@ -118,12 +120,45 @@ function renderPreviewThumb(previewUrl) {
}
/**
* Render showcase content: collapsed indicator bar by default, gallery
* (main viewer + thumbnail strip + import entry) when expanded
* Active showcase layout ('gallery' | 'vertical') from the persisted setting;
* unknown/missing values fall back to the gallery
* @returns {string}
*/
function getShowcaseLayout() {
return state.settings.showcase_layout === 'vertical' ? 'vertical' : 'gallery';
}
/**
* Render the segmented gallery/list layout toggle
* @param {string} layout - Active layout ('gallery' | 'vertical')
* @returns {string} HTML for the toggle
*/
function renderLayoutToggle(layout) {
const galleryLabel = translate('modals.model.showcase.layoutGallery', {}, 'Gallery view');
const listLabel = translate('modals.model.showcase.layoutList', {}, 'List view');
return `
<div class="showcase-layout-toggle" role="group">
<button type="button" class="layout-toggle-btn${layout === 'gallery' ? ' active' : ''}"
data-showcase-layout="gallery" title="${galleryLabel}"
aria-pressed="${layout === 'gallery'}">
<i class="fas fa-th-large"></i>
</button>
<button type="button" class="layout-toggle-btn${layout === 'vertical' ? ' active' : ''}"
data-showcase-layout="vertical" title="${listLabel}"
aria-pressed="${layout === 'vertical'}">
<i class="fas fa-list"></i>
</button>
</div>
`;
}
/**
* Render showcase content: collapsed indicator bar by default, the active
* layout (gallery or vertical list) when expanded
* @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files
* @param {string} previewUrl - Model preview URL for the collapsed indicator bar
* @param {boolean} expanded - Whether to render the full gallery (loads remote media)
* @param {boolean} expanded - Whether to render the full showcase (loads remote media)
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '', expanded = false) {
@@ -189,6 +224,8 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
${renderImportInterface(false)}
</div>` : '';
const layout = getShowcaseLayout();
// Collapsed resting state: a slim indicator bar only — remote examples are
// not rendered (and therefore not fetched) until the user expands.
if (!expanded) {
@@ -201,6 +238,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
<i class="fas fa-chevron-down"></i> ${translate('modals.model.showcase.showCount', { count }, `${showText} (${count})`)}
</button>
${hiddenNotification}
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
@@ -210,6 +248,26 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
`;
}
// Vertical list layout: every example stacked full-width
if (layout === 'vertical') {
return `
<div class="showcase-gallery showcase-vertical">
<div class="gallery-toolbar">
${hiddenNotification}
<button class="gallery-show-btn" id="galleryShowBtn">
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
</button>
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
</div>
${renderVerticalList(filteredImages, exampleFiles)}
${importZone}
</div>
`;
}
const showNav = count > 1;
const positionText = `${galleryState.activeIndex + 1} / ${count}`;
const activeImg = filteredImages[galleryState.activeIndex];
@@ -222,6 +280,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
<button class="gallery-show-btn" id="galleryShowBtn">
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
</button>
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
@@ -300,150 +359,15 @@ function renderThumbnail(img, index, exampleFiles) {
}
/**
* Render the active media item in the main viewer
* Render the active media item in the main viewer (display-optimized URL,
* wrapper sized by its container — see MediaRenderers.js)
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the media item
*/
function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
// Get original remote URL
const originalRemoteUrl = img.url || '';
// Determine media type for optimization
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Optimize CivitAI URLs for in-modal display (images capped at width=2400;
// the full-size media viewer uses getShowcaseUrl separately)
const remoteUrl = getDisplayUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
// Extract CivitAI image ID from CDN URL for import status check
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = translate('modals.model.showcase.nsfwR', {}, 'R-rated Content');
}
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfg_scale || meta.cfgScale || '';
const clipSkip = meta.clip_skip || meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Determine if this is a custom image (has id property)
const isCustomImage = Boolean(typeof img.id === 'string' && img.id);
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
// Create the media control buttons HTML
const mediaControlsHtml = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
${hasGenMeta ? `
<button class="media-control-btn create-recipe-btn"
title="Create As Recipe"
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-img-id="${img.id || ''}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
` : ''}
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
data-media-id="${img.id || ''}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'aria-disabled="true"' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>
</div>
`;
// Generate the appropriate wrapper based on media type
if (isVideo) {
return generateVideoWrapper(
img, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
return generateImageWrapper(
img, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
/**
* Find the matching local file for an image
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (typeof img.id === 'string' && img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
return renderShowcaseMediaItem(img, index, exampleFiles);
}
// URLs already warmed in the HTTP cache, so repeat navigations and re-renders
@@ -845,10 +769,12 @@ function isTypingTarget(target) {
}
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
// stay reserved for model-level navigation (ModelModal), and the full-size
// media viewer owns its keys while open.
// stay reserved for model-level navigation (ModelModal), the full-size
// media viewer owns its keys while open, and the vertical list has no
// active-item concept to navigate.
document.addEventListener('keydown', (event) => {
if (event.key !== '[' && event.key !== ']') return;
if (getShowcaseLayout() !== 'gallery') return;
if (!galleryState.expanded || galleryState.images.length < 2) return;
if (isTypingTarget(event.target)) return;
if (isMediaViewerOpen()) return;
@@ -892,6 +818,32 @@ function initStripVideoLazyLoading(gallery) {
videos.forEach(video => observer.observe(video));
}
/**
* Switch the showcase layout (gallery | vertical list), persist the setting
* immediately and re-render in place
* @param {string} layout - Target layout
*/
async function setShowcaseLayout(layout) {
if (layout !== 'gallery' && layout !== 'vertical') return;
if (layout === getShowcaseLayout()) return;
// Components reach the settings manager through the global set by
// core.js (importing it here would create a module cycle)
const sm = window.settingsManager;
if (sm) {
try {
await sm.saveSetting('showcase_layout', layout);
} catch (error) {
console.error('Failed to save showcase layout setting:', error);
return; // Keep the current layout when the save fails
}
} else {
state.settings.showcase_layout = layout;
}
rerenderGallery(galleryState.expanded);
}
/**
* Initialize all gallery interactions
* @param {HTMLElement} gallery - The .showcase-gallery element
@@ -899,16 +851,25 @@ function initStripVideoLazyLoading(gallery) {
export function initShowcaseContent(gallery) {
if (!gallery) return;
// While expanded the thumbnail strip occupies the modal's bottom-right
// corner; hide the back-to-top button there (Hide examples is the
// equivalent "return to top" affordance)
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', galleryState.expanded);
// Gallery layout only: while expanded the thumbnail strip occupies the
// modal's bottom-right corner; hide the back-to-top button there (Hide
// examples is the equivalent "return to top" affordance). The vertical
// list has no strip and is exactly where the button is needed most.
const hideBackToTop = galleryState.expanded && getShowcaseLayout() === 'gallery';
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', hideBackToTop);
// Toolbar: show/hide toggle (expanding renders the gallery and starts remote loads)
gallery.querySelector('#galleryShowBtn')?.addEventListener('click', () => {
rerenderGallery(!galleryState.expanded);
});
// Segmented layout toggle (gallery / vertical list)
gallery.querySelectorAll('[data-showcase-layout]').forEach(btn => {
btn.addEventListener('click', () => {
setShowcaseLayout(btn.dataset.showcaseLayout);
});
});
// Same expansion via mouse wheel near the bottom of the modal
setupScrollToExpand(gallery);
@@ -947,7 +908,7 @@ export function initShowcaseContent(gallery) {
handleExampleDeleted(e.detail?.shortId);
});
// Main viewer interactions (only exists in the expanded state)
// Main viewer interactions (only exists in the expanded gallery layout)
const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) {
initMainMediaInteractions(container);
@@ -960,6 +921,11 @@ export function initShowcaseContent(gallery) {
initStripVideoLazyLoading(gallery);
}
// Vertical list interactions (only exists in the expanded vertical layout)
if (gallery.querySelector('.vertical-list-container') && galleryState.expanded) {
initVerticalList(gallery, galleryState.images, galleryState.exampleFiles);
}
// Reposition controls on window resize
resizeBoundGallery = gallery;
}
@@ -0,0 +1,120 @@
/**
* VerticalListView.js
* Classic vertical example list for the model modal showcase: every example
* rendered full-width and stacked, each with its own metadata panel and media
* controls. Revived from the pre-gallery implementation (removed in v1.2.2),
* adapted to the current shared showcase infrastructure (MediaRenderers,
* MediaUtils) — no legacy scroll/metrics machinery.
*/
import {
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js';
import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
/**
* Render the vertical list: all examples stacked in a column
* @param {Array} images - Filtered images/videos to show
* @param {Array} exampleFiles - Local example files
* @returns {string} HTML content
*/
export function renderVerticalList(images, exampleFiles = []) {
return `
<div class="vertical-list-container">
${images.map((img, index) => renderVerticalMediaItem(img, index, exampleFiles)).join('')}
</div>
`;
}
/**
* Render a single vertical-list item. The wrapper is sized by the
* padding-bottom aspect-ratio hack so the column layout is stable before any
* media loads (and lazy loading has a box to intersect).
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the media item
*/
function renderVerticalMediaItem(img, index, exampleFiles) {
return renderShowcaseMediaItem(img, index, exampleFiles, {
heightPercent: computeHeightPercent(img),
fullQuality: true,
});
}
/**
* Compute the wrapper height as a padding-bottom percent of the modal width,
* clamped so very tall or very wide media still gets a reasonable box
* (defensive 4:3 fallback for missing dimensions prevents NaN layout)
* @param {Object} img - Image/video metadata
* @returns {number} padding-bottom percent
*/
function computeHeightPercent(img) {
const safeW = img.width || 4;
const safeH = img.height || 3;
const aspectRatio = (safeH / safeW) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
return Math.max(minHeightPercent, Math.min(maxHeightPercent, aspectRatio));
}
/**
* Build the item list for the full-size media viewer from the rendered items
* @param {Array} images - Rendered images/videos
* @param {Array} exampleFiles - Local example files
* @returns {Array<{url: string, type: string}>}
*/
function buildViewerItems(images, exampleFiles) {
return images.map((img, index) => {
const localFile = findLocalFile(img, index, exampleFiles);
const originalRemoteUrl = img.url || '';
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
return {
url: localFile?.path || getShowcaseUrl(originalRemoteUrl, isVideo ? 'video' : 'image'),
type: isVideo ? 'video' : 'image'
};
});
}
/**
* Initialize all vertical-list interactions: lazy loading, NSFW blur,
* per-item metadata panels and media controls, click-to-view
* @param {HTMLElement} container - The .showcase-vertical root element
* @param {Array} images - Rendered images/videos (aligned with DOM order)
* @param {Array} exampleFiles - Local example files
*/
export function initVerticalList(container, images, exampleFiles = []) {
if (!container) return;
initLazyLoading(container);
initNsfwBlurHandlers(container);
initMetadataPanelHandlers(container);
initMediaControlHandlers(container);
positionAllMediaControls(container);
// Click-to-view: open the full-size media viewer at the clicked item
const viewerItems = buildViewerItems(images, exampleFiles);
container.querySelectorAll('.vertical-list-container .media-wrapper').forEach((wrapper, index) => {
const mediaEl = wrapper.querySelector('img, video');
if (!mediaEl) return;
mediaEl.addEventListener('click', (e) => {
e.stopPropagation();
openMediaViewer(viewerItems, index);
});
});
// Reposition controls once media dimensions are known
container.querySelectorAll('.vertical-list-container img, .vertical-list-container video').forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(container));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(container));
}
});
}