/** * ui_gallery.js * The output gallery's listing and search (cards: ui_gallery_card.js), a model's generated * images and choosing a cover. Its large view is ui_gallery_viewer.js. */ import { app } from "../../../scripts/app.js"; import { translate } from './locales.js'; import { showImageWorkbench } from './ui_gallery_detail.js'; import { createGalleryCard } from './ui_gallery_card.js'; import { showGalleryViewer } from './ui_gallery_viewer.js'; import { loadKeptImages } from './image_keep.js'; import { createSearchChips } from './ui_search_chips.js'; import { newestCompareButton } from './ui_param_compare.js'; import { createGalleryKeptSwitch, createGalleryMediaSwitch } from './ui_gallery_filter.js'; import { appendGalleryFilter, galleryKeptOnly } from './gallery_filter.js'; const t = (key, params) => translate(key, params); export async function refreshGalleryImages() { if (this.galleryLoading || this.galleryRefreshLoading) return; this.galleryRefreshLoading = true; try { const scrollTop = this.galleryGrid?.scrollTop || 0; await this.loadGalleryImages(1, true); if (this.galleryGrid) this.galleryGrid.scrollTop = scrollTop; } catch (error) { console.warn('Could not refresh output gallery:', error); } finally { this.galleryRefreshLoading = false; } } /** Search blocks above the output gallery (the terms live on the browser as `gallerySearchTerms`), * with All / Pictures / Videos beside them. */ export function createGallerySearchBar(owner) { const header = document.createElement('div'); header.className = 'anomalous-gallery-header'; const search = createSearchChips({ placeholder: t('gallerySearchPlaceholder'), help: t('gallerySearchHelp'), onChange: (terms) => { owner.gallerySearchTerms = terms; owner.loadGalleryImages(1, true, { refresh: false }); }, }); search.element.classList.add('anomalous-gallery-search'); owner.gallerySearchCount = search.countElement; owner.galleryMediaSwitch = createGalleryMediaSwitch(() => owner.loadGalleryImages(1, true, { refresh: false })); const keptSwitch = createGalleryKeptSwitch(() => owner.loadGalleryImages(1, true, { refresh: false })); header.append(search.element, owner.galleryMediaSwitch, keptSwitch, newestCompareButton('anomalous-gallery-newest')); return header; } export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) { if (this.galleryLoading) { // A newer query arrived while loading: run it once the current request settles. if (reset) this.galleryReloadPending = true; return; } this.galleryLoading = true; const terms = [...(this.gallerySearchTerms || [])]; const termsKey = JSON.stringify(terms); const query = terms.length > 0; this.gallerySentinel.textContent = query && page === 1 ? t('gallerySearching') : t('galleryLoading'); try { const params = new URLSearchParams({ page: String(page), limit: '50' }); if (refresh) params.set('refresh', '1'); terms.forEach(term => params.append('term', term)); appendGalleryFilter(params); const res = await fetch(`/anomalous/gallery_images?${params}`); const data = await res.json(); if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) { this.galleryReloadPending = true; return; } this.galleryMediaSwitch?.refresh(Boolean(data.has_videos)); if (this.gallerySearchCount) { this.gallerySearchCount.textContent = query && Number.isFinite(data.total) ? t('gallerySearchCount', { count: data.total }) : ''; } if (reset) { // Clear existing cards const cards = this.galleryGrid.querySelectorAll('.anomalous-gallery-card'); cards.forEach(c => c.remove()); this.galleryLoaded = true; this.galleryImagesList = []; // The stars of this listing: what each image was kept as. this.galleryKept = loadKeptImages().catch(() => new Map()); } if (data.images && data.images.length > 0) { const incomingItems = data.images.map(imgData => { const q_sub = encodeURIComponent(imgData.subfolder); const q_file = encodeURIComponent(imgData.filename); return { filename: imgData.filename, subfolder: imgData.subfolder || '', media_type: imgData.media_type, url: `/view?filename=${q_file}&subfolder=${q_sub}&type=output`, sourceImage: { type: 'output', filename: imgData.filename, subfolder: imgData.subfolder || '', } }; }); if (reset) { this.galleryImagesList = incomingItems; } else { this.galleryImagesList = [...(this.galleryImagesList || []), ...incomingItems]; } for (const imgData of data.images) { this.galleryGrid.insertBefore(createGalleryCard(this, imgData, { showViewer: showGalleryViewer }), this.gallerySentinel); } this.galleryCurrentPage = page; this.galleryHasMore = page < data.pages; if (!this.galleryHasMore) { this.gallerySentinel.textContent = t('galleryNoMore'); } else { this.gallerySentinel.textContent = t('galleryScrollMore'); } } else { this.galleryHasMore = false; const empty = query ? 'gallerySearchEmpty' : galleryKeptOnly() ? 'galleryKeptEmpty' : 'galleryEmpty'; this.gallerySentinel.textContent = reset ? t(empty) : t('galleryNoMore'); } } catch (e) { console.error('Failed to load gallery images', e); this.gallerySentinel.textContent = t('galleryLoadFailed'); } finally { this.galleryLoading = false; if (this.galleryReloadPending) { this.galleryReloadPending = false; void this.loadGalleryImages(1, true, { refresh: false }); } } } export async function showGeneratedGallery(model) { let overlay = document.getElementById('anomalous-generated-gallery-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'anomalous-generated-gallery-overlay'; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.85)'; overlay.style.zIndex = '999999'; overlay.style.display = 'flex'; overlay.style.alignItems = 'center'; overlay.style.justifyContent = 'center'; const modalBox = document.createElement('div'); modalBox.id = 'anomalous-generated-gallery-modal'; modalBox.style.width = '95%'; modalBox.style.maxHeight = '95%'; modalBox.style.backgroundColor = 'var(--amb-lt-surface, var(--comfy-menu-bg, var(--amb-bg-card)))'; modalBox.style.borderRadius = '12px'; modalBox.style.display = 'flex'; modalBox.style.flexDirection = 'column'; modalBox.style.overflow = 'hidden'; modalBox.style.boxShadow = '0 10px 40px rgba(0, 0, 0, 0.8)'; const header = document.createElement('div'); header.style.padding = '15px 25px'; header.style.background = 'var(--amb-bg-card-hover)'; header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.style.borderBottom = '1px solid var(--amb-border-strong)'; const title = document.createElement('h2'); title.id = 'anomalous-generated-gallery-title'; title.style.margin = '0'; title.style.color = 'var(--amb-lt-text, #fff)'; const closeBtn = document.createElement('button'); closeBtn.textContent = `✖ ${t('galleryClose')}`; closeBtn.style.padding = '8px 15px'; closeBtn.style.background = '#dc3545'; closeBtn.style.color = '#fff'; closeBtn.style.border = 'none'; closeBtn.style.borderRadius = '5px'; closeBtn.style.cursor = 'pointer'; closeBtn.style.fontWeight = 'bold'; closeBtn.onmouseover = () => closeBtn.style.background = '#c82333'; closeBtn.onmouseout = () => closeBtn.style.background = '#dc3545'; closeBtn.onclick = () => { overlay.style.display = 'none'; }; header.appendChild(title); header.appendChild(closeBtn); modalBox.appendChild(header); const contentCont = document.createElement('div'); contentCont.id = 'anomalous-generated-gallery-content'; contentCont.style.flex = '1'; contentCont.style.overflowY = 'auto'; // Pictures scrolled up under the header still took the close button's clicks (lessons.md, 2026-10-12). contentCont.style.clipPath = 'inset(0)'; contentCont.style.padding = '20px'; contentCont.style.display = 'grid'; contentCont.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))'; contentCont.style.gap = '25px'; contentCont.style.rowGap = '40px'; contentCont.style.alignContent = 'start'; modalBox.appendChild(contentCont); overlay.appendChild(modalBox); document.getElementById('anomalous-container').appendChild(overlay); } const title = document.getElementById('anomalous-generated-gallery-title'); title.textContent = t('galleryHistoryTitle', { name: model.name || model.filename }); const contentCont = document.getElementById('anomalous-generated-gallery-content'); contentCont.innerHTML = ''; const loading = document.createElement('div'); loading.textContent = t('galleryScanning'); loading.style.textAlign = 'center'; loading.style.gridColumn = '1 / -1'; loading.style.padding = '50px'; loading.style.color = 'var(--amb-lt-muted, #aaa)'; contentCont.appendChild(loading); overlay.style.display = 'flex'; try { const res = await fetch('/anomalous/model_images?model_name=' + encodeURIComponent(model.filename) + '&t=' + Date.now()); const data = await res.json(); contentCont.innerHTML = ''; if (!data.images || data.images.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.textContent = t('galleryNoModelImages'); emptyMsg.style.textAlign = 'center'; emptyMsg.style.gridColumn = '1 / -1'; emptyMsg.style.padding = '50px'; emptyMsg.style.color = 'var(--amb-lt-muted, #888)'; contentCont.appendChild(emptyMsg); return; } data.images.forEach(img => { const imgCont = document.createElement('div'); imgCont.className = 'anomalous-card'; imgCont.style.cursor = 'pointer'; const el = document.createElement('img'); el.src = img.url || img; // Support both just in case el.loading = 'lazy'; el.draggable = true; el.addEventListener('dragstart', (e) => { const fullUrl = new URL(el.src, window.location.href).href; e.dataTransfer.setData('text/uri-list', fullUrl); e.dataTransfer.setData('text/plain', fullUrl); if (window.anomalousDragGhostImg) { e.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40); } }); let source_image = ""; let filenameText = ""; if (img.url) { try { const urlParams = new URLSearchParams(img.url.split('?')[1]); filenameText = urlParams.get('filename') || ''; const sub = urlParams.get('subfolder') || ''; source_image = sub ? sub + '/' + filenameText : filenameText; } catch (e) { } } else { filenameText = img.split('/').pop().split('?')[0]; source_image = filenameText; } const titleDiv = document.createElement('div'); titleDiv.className = 'anomalous-card-title'; titleDiv.innerText = filenameText; const setCoverBtn = document.createElement('button'); setCoverBtn.textContent = t('gallerySetCover'); setCoverBtn.style.position = 'absolute'; setCoverBtn.style.bottom = '40px'; setCoverBtn.style.right = '5px'; setCoverBtn.style.background = 'rgba(40, 167, 69, 0.85)'; setCoverBtn.style.color = '#fff'; setCoverBtn.style.border = '1px solid var(--amb-border-strong)'; setCoverBtn.style.borderRadius = '4px'; setCoverBtn.style.padding = '4px 8px'; setCoverBtn.style.cursor = 'pointer'; setCoverBtn.style.zIndex = '10'; setCoverBtn.style.fontSize = '12px'; setCoverBtn.onclick = (e) => { e.stopPropagation(); if (!confirm(t('gallerySetCoverConfirm'))) return; fetch('/anomalous/set_custom_cover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder, filename: model.filename, source_image: source_image }) }).then(res => res.json()).then(async result => { if (result.status === 'success') { alert(t('galleryCoverSuccess')); await this.loadModels(); const updatedModel = this.models.find(m => m.filename === model.filename); if (updatedModel && this.currentDetailModel && this.currentDetailModel.filename === model.filename) { this.showDetail(updatedModel); } } else { alert(t('galleryErrorPrefix') + result.message); } }).catch(err => { alert(t('galleryErrorPrefix') + err.message); }); }; imgCont.onclick = () => { const allGenCards = Array.from(contentCont.querySelectorAll('.anomalous-card')); const curIndex = allGenCards.indexOf(imgCont); const genItems = (data.images || []).map(im => { const u = im.url || im; let fn = ''; let sub = ''; if (im.url) { try { const up = new URLSearchParams(im.url.split('?')[1]); fn = up.get('filename') || ''; sub = up.get('subfolder') || ''; } catch (_) {} } else { fn = String(im).split('/').pop().split('?')[0]; } return { filename: fn, subfolder: sub, url: u, sourceImage: { type: 'output', filename: fn, subfolder: sub } }; }); void showImageWorkbench(this, { type: 'output', filename: filenameText, subfolder: source_image.includes('/') ? source_image.split('/')[0] : '', }, img.url || img, { items: genItems, currentIndex: curIndex >= 0 ? curIndex : 0, }); }; imgCont.appendChild(el); imgCont.appendChild(setCoverBtn); imgCont.appendChild(titleDiv); contentCont.appendChild(imgCont); }); } catch (e) { const error = document.createElement('div'); error.textContent = t('galleryLoadImagesError'); error.style.color = 'red'; error.style.textAlign = 'center'; error.style.gridColumn = '1 / -1'; error.style.padding = '50px'; contentCont.replaceChildren(error); } } export function showGallerySelectMode(model) { this.gallerySelectModel = model; this.grid.style.display = 'none'; this.detailPanel.style.display = 'none'; this.galleryPanel.style.display = 'flex'; this.galleryPanel.classList.add('is-cover-selecting'); let banner = document.getElementById('anomalous-gallery-select-banner'); if (!banner) { banner = document.createElement('div'); banner.id = 'anomalous-gallery-select-banner'; banner.style.background = '#28a745'; banner.style.color = '#fff'; banner.style.padding = '10px'; banner.style.textAlign = 'center'; banner.style.fontWeight = 'bold'; banner.style.position = 'sticky'; banner.style.top = '0'; banner.style.zIndex = '1000'; this.galleryPanel.insertBefore(banner, this.galleryPanel.firstChild); } banner.style.display = 'block'; banner.replaceChildren(); const bannerPrefix = document.createTextNode(t('gallerySelectingCoverPrefix')); const bannerModel = document.createElement('span'); bannerModel.textContent = model.filename; bannerModel.style.color = '#ff0'; const bannerSuffix = document.createTextNode(t('gallerySelectingCoverSuffix')); const cancelSelect = document.createElement('button'); cancelSelect.id = 'anomalous-cancel-select'; cancelSelect.textContent = t('galleryCancel'); cancelSelect.style.marginLeft = '15px'; cancelSelect.style.color = '#000'; cancelSelect.style.background = '#fff'; cancelSelect.style.border = 'none'; cancelSelect.style.padding = '2px 8px'; cancelSelect.style.borderRadius = '4px'; cancelSelect.style.cursor = 'pointer'; banner.append(bannerPrefix, bannerModel, bannerSuffix, cancelSelect); cancelSelect.onclick = () => { const tempModel = this.gallerySelectModel; this.gallerySelectModel = null; this.galleryPanel.classList.remove('is-cover-selecting'); banner.style.display = 'none'; this.galleryPanel.style.display = 'none'; if (this.currentDetailModel) { this.detailPanel.style.display = 'flex'; } else { this.grid.style.display = 'grid'; } if (tempModel) { this.showEditModal(tempModel); } }; if (!this.galleryLoaded) { this.loadGalleryImages(1, true); } }