import { app } from "../../../../../scripts/app.js"; import { api } from "../../../../../scripts/api.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; export const modelInfoMethods = { /** * Handle click outside context menu to hide it */ handleContextMenuOutsideClick(e) { if (!this.contextMenu) return; if (this.contextMenu.style.display === 'none') return; // Check if click is outside the context menu if (!this.contextMenu.contains(e.target)) { this.hideContextMenu(); } }, /** * Show context menu at the specified position */ showContextMenu(x, y, model) { if (!this.contextMenu) return; this._contextMenuModel = model; const canShowMore = this.canShowSourceDetails(model); const isDownloadTableContext = model?.context_scope === 'download_table'; const isDownloadFolderContext = model?.context_scope === 'download_folder'; const isDownloadRootContext = model?.context_scope === 'download_root'; const isDownloadQueueContext = model?.context_scope === 'download_queue'; const isDownloadHistoryContext = model?.context_scope === 'download_history'; const isFolderOnlyContext = isDownloadFolderContext || isDownloadRootContext; const isSourceModelContext = !isDownloadTableContext && !isFolderOnlyContext && !isDownloadQueueContext && !isDownloadHistoryContext; const hasLocalPath = Boolean(model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path); const showOpenFolder = !isDownloadTableContext && hasLocalPath; const showSwitchWorkflow = (isDownloadQueueContext || isDownloadHistoryContext) && Boolean(this.canSwitchToDownloadWorkflow?.(model)); this.setContextMenuItemVisible('showInfo', isSourceModelContext); this.setContextMenuItemVisible('showMore', canShowMore); this.setContextMenuItemVisible('civitai', isSourceModelContext); this.setContextMenuItemVisible('switchWorkflow', showSwitchWorkflow); this.setContextMenuItemVisible('openFolder', showOpenFolder); this.setContextMenuDividerVisible('source', isSourceModelContext || canShowMore); this.setContextMenuDividerVisible('workflow', showSwitchWorkflow && (isSourceModelContext || canShowMore)); this.setContextMenuDividerVisible('folder', showOpenFolder && (isSourceModelContext || canShowMore || showSwitchWorkflow)); const openFolderLabel = this.contextMenu.querySelector('.mr-context-menu-action-open-folder span:last-child'); if (openFolderLabel) { openFolderLabel.textContent = model?.open_folder_label || (isDownloadRootContext ? 'Open Root Folder' : isDownloadFolderContext ? 'Open Download Folder' : 'Open Containing Folder'); } // Position the menu this.contextMenu.style.left = `${x}px`; this.contextMenu.style.top = `${y}px`; this.contextMenu.style.display = 'block'; // Adjust position if menu would go off screen const rect = this.contextMenu.getBoundingClientRect(); if (rect.right > window.innerWidth) { this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`; } if (rect.bottom > window.innerHeight) { this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`; } }, /** * Hide context menu */ hideContextMenu() { if (!this.contextMenu) return; this.contextMenu.style.display = 'none'; this._contextMenuModel = null; }, /** * Handle context menu item click */ handleContextMenuAction(action) { const model = this._contextMenuModel; this.hideContextMenu(); if (!model) return; if (action === 'civitai') { this.openInCivitAI(model); } else if (action === 'openFolder') { this.openContainingFolder(model); } else if (action === 'switchWorkflow') { this.switchToDownloadWorkflow(model); } else if (action === 'showInfo') { this.showModelInfo(model); } else if (action === 'showMore') { this.showSourceModelDetails(model); } }, setContextMenuItemVisible(action, visible) { if (!this.contextMenu) return; const className = `mr-context-menu-action-${String(action).replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)}`; this.contextMenu.querySelectorAll(`[data-menu-action="${action}"], .${className}`).forEach(item => { item.hidden = !visible; item.classList.toggle('mr-context-menu-hidden', !visible); if (visible) { item.style.removeProperty('display'); } else { item.style.setProperty('display', 'none', 'important'); } }); }, setContextMenuDividerVisible(name, visible) { if (!this.contextMenu) return; this.contextMenu.querySelectorAll(`[data-menu-divider="${name}"], .mr-context-menu-divider-${name}`).forEach(item => { item.hidden = !visible; item.classList.toggle('mr-context-menu-hidden', !visible); if (visible) { item.style.removeProperty('display'); } else { item.style.setProperty('display', 'none', 'important'); } }); }, canShowSourceDetails(model = {}) { const source = String(model.details_source || model.source || '').toLowerCase(); return model?.context_scope === 'download_table' && ['civitai', 'civarchive', 'lora_manager_archive'].includes(source) && Boolean(model.model_id || model.modelId); }, getMissingByKey(key = '') { if (!key) return null; return (this.missingModels || []).find(missing => this.getMissingModelKey?.(missing) === key) || null; }, async openContainingFolder(model) { const path = model?.open_path || model?.folder_path || model?.download_directory || model?.directory || model?.path || model?.resolved_path || ''; if (!path) { this.showNotification('No local file path available', 'error'); return; } try { await this.fetchJson('/model_resolver/open-containing-folder', { method: 'POST', body: JSON.stringify({ path }) }, 'Open containing folder'); } catch (error) { // Already logged and notified inside fetchJson } }, /** * Open model in CivitAI */ async openInCivitAI(model) { if (!model) return; const directUrl = this.getKnownCivitaiModelUrl(model); if (directUrl) { window.open(directUrl, '_blank'); return; } const name = model.name || model.original_path?.split(/[\/\\]/).pop() || ''; if (!name) return; try { // Search CivitAI for this model using hash (pass resolved_path for hash lookup) const data = await this.fetchJson('/model_resolver/civitai-search', { method: 'POST', body: JSON.stringify({ filename: name, category: model.category, resolved_path: model.resolved_path || '' }) }, 'Search CivitAI'); if (data && data.url) { window.open(data.url, '_blank'); } else { throw new Error('No URL in search result'); } } catch (e) { // Fall back to direct search const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`; window.open(searchUrl, '_blank'); } }, getKnownCivitaiModelUrl(model = {}) { const candidates = [ model.model_url, model.workflow_model_url, model.openUrl, model.url ]; for (const url of candidates) { const value = String(url || ''); if (/^https:\/\/(?:www\.)?civitai\.com\/models\/\d+/i.test(value)) { return value; } } const modelId = model.model_id || model.modelId; const versionId = model.version_id || model.versionId; if (modelId) { return `https://civitai.com/models/${modelId}${versionId ? `?modelVersionId=${versionId}` : ''}`; } return ''; }, /** * Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog) */ async showModelInfo(model) { if (!model) return; const name = model.name || model.original_path?.split(/[\/\\]/).pop() || ''; if (!name) return; // Create and show the info dialog this.showModelInfoDialog(name, model); }, /** * Show the model info dialog */ showModelInfoDialog(loraName, modelData) { // Create info dialog element const dialog = this.createInfoDialog(loraName, modelData); this.restoreInfoDialogSize(dialog); // Show the dialog document.body.appendChild(dialog); this.bindTooltips(dialog); this.bindInfoDialogResizePersistence(dialog); dialog._onInfoDialogKeyDown = (event) => { if (event.key !== 'Escape') return; if (document.querySelector('.mr-image-preview-backdrop')) return; event.preventDefault(); this.closeInfoDialog(dialog); }; document.addEventListener('keydown', dialog._onInfoDialogKeyDown); // Add close handlers const closeBtn = dialog.querySelector('.mr-info-dialog-close'); const footerCloseBtn = dialog.querySelector('.mr-info-dialog-close-btn'); const backdrop = dialog.querySelector('.mr-info-dialog-backdrop'); if (closeBtn) { closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog)); } if (footerCloseBtn) { footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog)); } if (backdrop) { backdrop.addEventListener('click', (e) => { // Only close if clicking backdrop itself, not its children if (e.target === backdrop) { this.closeInfoDialog(dialog); } }); } dialog._infoDialogLookup = { loraName, modelData }; this.fetchLocalModelInfoForDialog(loraName, modelData, dialog); }, /** * Create the info dialog element */ renderInfoFieldLabel(iconName, label, tooltip) { return ` ${getSvgIcon(iconName, 'currentColor', 'mr-info-label-icon')} ${this.escapeHtml(label)} ? `; }, createInfoDialog(loraName, modelData) { const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const dialog = document.createElement('div'); dialog.className = 'mr-info-dialog-backdrop'; dialog._selectedTrainedWords = new Set(); dialog.innerHTML = `

${loraDisplayName}

Loading...
Tags
${this.renderInfoFieldLabel('file', 'File', 'The model file name found locally or returned by CivitAI.')}
${this.renderInfoFieldLabel('box', 'Base Model', 'Base model this resource was made for, for example SD1.5, SDXL or Flux.')}
${this.renderInfoFieldLabel('hash', 'Hash (sha256)', 'Unique fingerprint of the local file. Model Resolver uses it to confirm the exact CivitAI version.')}
0%
${this.renderInfoFieldLabel('hardDrive', 'Size', 'The local model file size, read from metadata or from the file on disk.')}
${this.renderInfoFieldLabel('locate', 'Location', 'Folder where this local model file is stored.')}
${this.renderInfoFieldLabel('bookType', 'Trained Words', 'Trigger words recommended by the model author. Click the words you want, then copy them into your prompt.')} 0 selected
Click words to select them.
${this.renderInfoFieldLabel('wrench', 'Clip Skip', 'Recommended Clip Skip value from the model author, if one is provided.')}
${this.renderInfoFieldLabel('fileText', 'Description', 'Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.')}
`; return dialog; }, escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, escapeJsString(value) { return this.escapeHtml(JSON.stringify(String(value ?? ''))); }, truncateText(value, maxLength = 160) { const text = String(value ?? '').trim(); if (!text) return ''; return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; }, sanitizeDescriptionHtml(html) { const raw = String(html ?? '').trim(); if (!raw) return ''; const parser = new DOMParser(); const doc = parser.parseFromString(`
${raw}
`, 'text/html'); const root = doc.body.firstElementChild; if (!root) return this.escapeHtml(raw); const allowedTags = new Set([ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'span' ]); const allowedStyles = new Set(['color']); const sanitizeNode = (node) => { if (node.nodeType === Node.TEXT_NODE) { return; } if (node.nodeType !== Node.ELEMENT_NODE) { node.remove(); return; } const tag = node.tagName.toLowerCase(); if (!allowedTags.has(tag)) { const parent = node.parentNode; if (!parent) { node.remove(); return; } while (node.firstChild) { parent.insertBefore(node.firstChild, node); } parent.removeChild(node); return; } const attrs = Array.from(node.attributes); for (const attr of attrs) { const name = attr.name.toLowerCase(); if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) { node.removeAttribute(attr.name); continue; } if (name === 'href' && tag === 'a') { const href = node.getAttribute('href') || ''; if (!/^https?:\/\//i.test(href)) { node.removeAttribute('href'); } else { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer'); } continue; } if (name === 'style') { const safeStyles = []; const styleValue = node.getAttribute('style') || ''; for (const part of styleValue.split(';')) { const [prop, value] = part.split(':').map(v => v?.trim()); if (!prop || !value) continue; if (allowedStyles.has(prop.toLowerCase())) { safeStyles.push(`${prop}: ${value}`); } } if (safeStyles.length) { node.setAttribute('style', safeStyles.join('; ')); } else { node.removeAttribute('style'); } continue; } if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) { node.removeAttribute(attr.name); } } Array.from(node.childNodes).forEach(child => sanitizeNode(child)); }; Array.from(root.childNodes).forEach(child => sanitizeNode(child)); return root.innerHTML; }, normalizeTrainedWords(words) { if (Array.isArray(words)) { return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))]; } if (typeof words === 'string') { return [...new Set( words .split(/[\n,|;]/) .map(word => word.trim()) .filter(Boolean) )]; } if (words && typeof words === 'object') { return this.normalizeTrainedWords( words.trained_words || words.trainedWords || words.words || words.values || [] ); } return []; }, formatInfoDialogLocation(data = {}) { let location = data.location || data.folder_path || data.folderPath || data.download_directory || data.downloadDirectory || data.directory || ''; if (!location) { const fullPath = data.file_path || data.filePath || data.resolved_path || data.resolvedPath || data.full_path || data.fullPath || data.path || ''; const normalizedFullPath = String(fullPath || '').replace(/\\/g, '/'); const lastSlash = normalizedFullPath.lastIndexOf('/'); if (lastSlash > 0) { location = normalizedFullPath.slice(0, lastSlash + 1); } } location = String(location || '').trim().replace(/\\/g, '/'); if (location && !location.endsWith('/')) { location += '/'; } return location; }, getInfoDialogLocationOpenPath(data = {}) { return data.file_path || data.filePath || data.resolved_path || data.resolvedPath || data.full_path || data.fullPath || data.path || data.location || data.folder_path || data.folderPath || data.download_directory || data.downloadDirectory || data.directory || ''; }, normalizeInfoTags(tags) { if (Array.isArray(tags)) { const seen = new Set(); const result = []; for (let tag of tags) { if (tag && typeof tag === 'object') { tag = tag.name || tag.tag || tag.text || tag.value || ''; } const text = String(tag || '').trim(); if (!text) continue; const key = text.toLowerCase(); if (seen.has(key)) continue; seen.add(key); result.push(text); } return result; } if (typeof tags === 'string') { return this.normalizeInfoTags( tags .split(/[\n,|;]/) .map(tag => tag.trim()) .filter(Boolean) ); } if (tags && typeof tags === 'object') { return this.normalizeInfoTags( tags.tags || tags.model_tags || tags.modelTags || tags.values || [] ); } return []; }, getInfoDialogTags(data = {}) { const sources = [ data.tags, data.model_tags, data.modelTags, data.civitai?.tags, data.civitai?.model?.tags, data.model?.tags, data.metadata?.tags, data.path_metadata?.tags ]; return this.normalizeInfoTags( sources.flatMap(source => this.normalizeInfoTags(source)) ); }, formatInfoDialogSize(data = {}) { const rawSize = data.size ?? data.file_size ?? data.fileSize ?? data.size_bytes ?? data.sizeBytes ?? data.file?.size ?? data.file?.sizeBytes ?? null; if (rawSize === null || rawSize === undefined || rawSize === '') return ''; if (rawSize === 0 || rawSize === '0') return '0 B'; const numericSize = Number(rawSize); if (Number.isFinite(numericSize)) { return typeof this.formatBytes === 'function' ? this.formatBytes(numericSize) : `${numericSize} B`; } return String(rawSize); }, getInfoDialogTrainedWords(data = {}) { const sources = [ data.trained_words, data.trainedWords, data.civitai?.trainedWords, data.civitai?.trained_words, data.selected_version?.trainedWords, data.selected_version?.trained_words, data.modelVersion?.trainedWords, data.modelVersion?.trained_words, data.model_version?.trainedWords, data.model_version?.trained_words, data.version?.trainedWords, data.version?.trained_words, data.path_metadata?.trainedWords, data.path_metadata?.trained_words, data.metadata?.trainedWords, data.metadata?.trained_words ]; return this.normalizeTrainedWords( sources.flatMap(source => this.normalizeTrainedWords(source)) ); }, setInfoTableRowVisible(row, visible) { if (!row) return; row.classList.toggle('mr-hidden-initial', !visible); row.style.display = visible ? '' : 'none'; }, setInfoElementVisible(element, visible) { if (!element) return; element.classList.toggle('mr-hidden-initial', !visible); element.style.display = visible ? '' : 'none'; }, updateSelectedTrainedWordsSummary(dialog) { if (!dialog) return; const countEl = dialog.querySelector('.mr-info-trained-words-count'); const copyBtn = dialog.querySelector('.mr-info-copy-trained-words'); const selected = dialog._selectedTrainedWords instanceof Set ? Array.from(dialog._selectedTrainedWords) : []; if (countEl) { countEl.textContent = `${selected.length} selected`; } if (copyBtn) { copyBtn.disabled = selected.length === 0; copyBtn.textContent = 'Copy'; } }, getInfoDialogHash(data = {}) { return String(data.sha256 || data.hash || data.hashes?.SHA256 || data.hashes?.sha256 || '').trim(); }, getInfoDialogModelFilePath(data = {}) { return data.file_path || data.filePath || data.resolved_path || data.resolvedPath || data.full_path || data.fullPath || data.path || ''; }, updateInfoDialogHashDisplay(dialog, data = {}) { const hashEl = dialog.querySelector('.mr-info-hash'); const button = dialog.querySelector('.mr-info-hash-calculate'); const progressEl = dialog.querySelector('.mr-info-hash-progress'); const hash = this.getInfoDialogHash(data); const filePath = this.getInfoDialogModelFilePath(data); if (hashEl) { hashEl.textContent = hash; hashEl.classList.toggle('is-empty', !hash); } if (button) { const canCalculate = Boolean(!hash && filePath); button.classList.toggle('mr-hidden-initial', !canCalculate); button.hidden = !canCalculate; button.disabled = false; button.textContent = 'Calculate hash'; delete button.dataset.hashCalculating; delete button.dataset.progressId; } if (progressEl && hash) { this.hideInfoDialogHashProgress(dialog); } }, hideInfoDialogHashProgress(dialog) { const progressEl = dialog.querySelector('.mr-info-hash-progress'); if (!progressEl) return; const bar = progressEl.querySelector('.mr-info-hash-progress-bar span'); const label = progressEl.querySelector('small'); progressEl.classList.add('mr-hidden-initial'); progressEl.hidden = true; if (bar) bar.style.width = '0%'; if (label) label.textContent = '0%'; }, updateInfoDialogHashProgress(dialog, percent = 0, message = '') { const progressEl = dialog.querySelector('.mr-info-hash-progress'); if (!progressEl) return; const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); const bar = progressEl.querySelector('.mr-info-hash-progress-bar span'); const label = progressEl.querySelector('small'); progressEl.classList.remove('mr-hidden-initial'); progressEl.hidden = false; if (bar) bar.style.width = `${safePercent}%`; if (label) { label.textContent = message ? `${Math.round(safePercent)}% · ${message}` : `${Math.round(safePercent)}%`; } }, async cancelInfoDialogHashCalculation(dialog, button) { const state = dialog?._hashCalculation || {}; state.cancelRequested = true; dialog._hashCalculation = state; if (button) { button.disabled = true; button.textContent = 'Stopping...'; } if (state.progressId) { try { await this.fetchJson( `/model_resolver/calculate-file-hash/cancel/${encodeURIComponent(state.progressId)}`, { method: 'POST', silent: true }, 'Cancel model hash calculation' ); } catch (error) { // Keep the UI in stopping state; polling will surface the final state if possible. } } }, async calculateInfoDialogHash(dialog, button) { if (button?.dataset.hashCalculating === 'true') { await this.cancelInfoDialogHashCalculation(dialog, button); return null; } const data = dialog?._infoDialogData || {}; const filePath = this.getInfoDialogModelFilePath(data); if (!filePath) { this.showNotification?.('No local file path available', 'error'); return null; } dialog._hashCalculation = { progressId: '', cancelRequested: false }; if (button) { button.disabled = false; button.textContent = 'Stop calculate'; button.dataset.hashCalculating = 'true'; delete button.dataset.progressId; } this.updateInfoDialogHashProgress(dialog, 0, 'Preparing'); try { const start = await this.fetchJson('/model_resolver/calculate-file-hash/start', { method: 'POST', body: JSON.stringify({ file_path: filePath, metadata_path: data.metadata_path || data.metadataPath || '' }) }, 'Start model hash calculation'); const progressId = start?.progress_id; if (!progressId) { throw new Error('No progress id returned'); } if (!dialog._hashCalculation) { dialog._hashCalculation = { progressId, cancelRequested: false }; } dialog._hashCalculation.progressId = progressId; if (button) { button.dataset.progressId = progressId; button.textContent = 'Stop calculate'; button.disabled = false; } if (dialog._hashCalculation.cancelRequested) { await this.cancelInfoDialogHashCalculation(dialog, button); } let result = null; for (;;) { await new Promise(resolve => setTimeout(resolve, 250)); if (!dialog.isConnected) { await this.cancelInfoDialogHashCalculation(dialog, button); return null; } const progress = await this.fetchJson( `/model_resolver/calculate-file-hash/progress/${encodeURIComponent(progressId)}`, { silent: true }, 'Get model hash progress' ); this.updateInfoDialogHashProgress( dialog, progress?.percent || 0, progress?.message || '' ); if (progress?.status === 'done') { result = progress; break; } if (progress?.status === 'cancelled') { this.hideInfoDialogHashProgress(dialog); if (button) { button.disabled = false; button.textContent = 'Calculate hash'; delete button.dataset.hashCalculating; delete button.dataset.progressId; } dialog._hashCalculation = null; this.showNotification?.('Hash calculation stopped.', 'info'); return null; } if (progress?.status === 'error') { throw new Error(progress.error || progress.message || 'Hash calculation failed'); } } const sha256 = result?.sha256 || result?.hash || ''; if (!sha256) { throw new Error('No hash returned'); } const updatedData = { ...data, sha256, hash: sha256, metadata_path: result.metadata_path || data.metadata_path || data.metadataPath || '', metadata_saved: Boolean(result.metadata_updated || data.metadata_saved) }; dialog._infoDialogData = updatedData; dialog._hashCalculation = null; this.updateInfoDialogHashDisplay(dialog, updatedData); this.showNotification?.( result.metadata_updated ? 'Hash calculated and saved to metadata.' : 'Hash calculated.', 'success' ); return updatedData; } catch (error) { dialog._hashCalculation = null; if (button) { button.disabled = false; button.textContent = 'Calculate hash'; delete button.dataset.hashCalculating; delete button.dataset.progressId; } this.hideInfoDialogHashProgress(dialog); this.showNotification?.(`Hash calculation failed: ${error?.message || error}`, 'error'); return null; } }, bindInfoDialogInteractions(dialog) { if (!dialog || dialog.dataset.mlInfoBound === 'true') return; dialog.dataset.mlInfoBound = 'true'; dialog.addEventListener('click', async (event) => { const fetchCivitaiButton = event.target.closest('.mr-info-fetch-civitai'); if (fetchCivitaiButton && dialog.contains(fetchCivitaiButton)) { await this.fetchCivitaiModelInfoForDialog(dialog, fetchCivitaiButton); return; } const hashButton = event.target.closest('.mr-info-hash-calculate'); if (hashButton && dialog.contains(hashButton)) { await this.calculateInfoDialogHash(dialog, hashButton); return; } const locationBtn = event.target.closest('.mr-info-location-button'); if (locationBtn && dialog.contains(locationBtn)) { const path = locationBtn.dataset.path || ''; if (path) { await this.openContainingFolder({ path, resolved_path: path }); } return; } const wordBtn = event.target.closest('.mr-info-trained-word'); if (wordBtn && dialog.contains(wordBtn)) { const word = wordBtn.dataset.word || ''; if (word) { if (!(dialog._selectedTrainedWords instanceof Set)) { dialog._selectedTrainedWords = new Set(); } if (dialog._selectedTrainedWords.has(word)) { dialog._selectedTrainedWords.delete(word); wordBtn.classList.remove('is-selected'); wordBtn.setAttribute('aria-pressed', 'false'); } else { dialog._selectedTrainedWords.add(word); wordBtn.classList.add('is-selected'); wordBtn.setAttribute('aria-pressed', 'true'); } this.updateSelectedTrainedWordsSummary(dialog); } return; } const copyBtn = event.target.closest('.mr-info-copy-trained-words'); if (copyBtn && dialog.contains(copyBtn)) { const words = dialog._selectedTrainedWords instanceof Set ? Array.from(dialog._selectedTrainedWords) : []; if (!words.length) return; try { await navigator.clipboard.writeText(words.join(', ')); copyBtn.textContent = 'Copied'; } catch (error) { console.error('Model Resolver: Failed to copy trained words:', error); copyBtn.textContent = 'Failed'; } setTimeout(() => { if (copyBtn.isConnected) { copyBtn.textContent = 'Copy'; } }, 1200); return; } const descToggleBtn = event.target.closest('.mr-info-description-toggle'); if (descToggleBtn && dialog.contains(descToggleBtn)) { const descEl = dialog.querySelector('.mr-info-description'); if (!descEl) return; const isExpanded = descEl.classList.toggle('is-expanded'); descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more'; } }); }, getInfoDialogElement(dialog) { return dialog?.querySelector?.('.mr-info-dialog') || null; }, restoreInfoDialogSize(dialog) { const panel = this.getInfoDialogElement(dialog); if (!panel) return; try { const saved = JSON.parse(localStorage.getItem('model_resolver_info_dialog_size') || 'null'); if (!saved || typeof saved !== 'object') return; const width = Number(saved.w); const height = Number(saved.h); if (!Number.isFinite(width) || !Number.isFinite(height)) return; const maxWidth = Math.floor(window.innerWidth * 0.92); const maxHeight = Math.max(320, window.innerHeight - 16); const clampedWidth = Math.max(420, Math.min(width, maxWidth)); const clampedHeight = Math.max(320, Math.min(height, maxHeight)); panel.style.width = `${clampedWidth}px`; panel.style.height = `${clampedHeight}px`; } catch (error) { console.warn('Model Resolver: Failed to restore info dialog size:', error); } }, saveInfoDialogSize(dialog) { const panel = this.getInfoDialogElement(dialog); if (!panel) return; const rect = panel.getBoundingClientRect(); const width = Math.round(rect.width); const height = Math.round(rect.height); if (!width || !height) return; try { localStorage.setItem('model_resolver_info_dialog_size', JSON.stringify({ w: width, h: height })); } catch (error) { console.warn('Model Resolver: Failed to save info dialog size:', error); } }, bindInfoDialogResizePersistence(dialog) { const panel = this.getInfoDialogElement(dialog); if (!panel || typeof ResizeObserver === 'undefined') return; if (dialog._infoDialogResizeObserver) return; let resizeSaveTimer = null; const observer = new ResizeObserver(() => { clearTimeout(resizeSaveTimer); resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180); dialog._infoDialogResizeSaveTimer = resizeSaveTimer; }); observer.observe(panel); dialog._infoDialogResizeObserver = observer; dialog._infoDialogResizeSaveTimer = resizeSaveTimer; }, getModelInfoResolvedPath(modelData = {}) { return modelData?.resolved_path || modelData?.path || modelData?.full_path || modelData?.folder_path || modelData?.download_directory || ''; }, buildLocalInfoDialogData(loraName, modelData = {}) { const filename = loraName || modelData?.filename || modelData?.name || ''; const modelName = String(filename || '').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const resolvedPath = this.getModelInfoResolvedPath(modelData); const categoryTypeMap = { checkpoints: 'checkpoint', loras: 'lora', vae: 'vae', text_encoders: 'text_encoder', clip: 'clip', clip_vision: 'clip_vision', controlnet: 'controlnet', upscale_models: 'upscale', diffusion_models: 'diffusion_model' }; const rawType = modelData?.model_type || modelData?.modelType || modelData?.type || modelData?.category || ''; const normalizedType = String(rawType || '').trim().toLowerCase().replace(/[\s-]+/g, '_'); return { filename, category: modelData?.category || '', file_path: resolvedPath, resolved_path: resolvedPath, location: resolvedPath ? this.formatInfoDialogLocation({ file_path: resolvedPath }) : '', model_name: modelData?.model_name || modelData?.modelName || modelName, model_type: categoryTypeMap[normalizedType] || rawType, sha256: modelData?.sha256 || modelData?.hash || modelData?.hashes?.SHA256 || '', size: modelData?.size || modelData?.file_size || modelData?.fileSize || '', base_model: modelData?.base_model || modelData?.baseModel || '', tags: modelData?.tags || [], trained_words: modelData?.trained_words || modelData?.trainedWords || [], local_only: true, civitai_checked: false }; }, /** * Fetch local model info and update the dialog without querying CivitAI. */ async fetchLocalModelInfoForDialog(loraName, modelData, dialog) { const requestId = `${Date.now()}_${Math.random()}`; dialog._localInfoRequestId = requestId; const fallbackData = this.buildLocalInfoDialogData(loraName, modelData); this.updateInfoDialogWithData(dialog, fallbackData); try { const data = await this.fetchJson('/model_resolver/civitai-search', { method: 'POST', body: JSON.stringify({ filename: loraName, category: modelData?.category || '', resolved_path: this.getModelInfoResolvedPath(modelData), local_only: true }) }, 'Fetch local model info'); if ( dialog._localInfoRequestId !== requestId || dialog._infoDialogCivitaiFetchStarted || dialog._infoDialogData?.civitai_checked || dialog._infoDialogData?.version_url || dialog._infoDialogData?.url ) { return; } this.updateInfoDialogWithData(dialog, { ...fallbackData, ...data, civitai_checked: false }); } catch (e) { if (dialog._localInfoRequestId !== requestId || dialog._infoDialogCivitaiFetchStarted) { return; } this.updateInfoDialogWithData(dialog, fallbackData); } }, async fetchCivitaiModelInfoForDialog(dialog, button = null) { if (!dialog) return; if (button?.dataset.fetchingCivitai === 'true') return; const forceRefresh = button?.dataset.forceRefresh === 'true'; const lookup = dialog._infoDialogLookup || {}; let data = dialog._infoDialogData || {}; const filename = data.filename || lookup.loraName || ''; const category = data.category || lookup.modelData?.category || ''; const resolvedPath = this.getInfoDialogModelFilePath(data) || this.getModelInfoResolvedPath(lookup.modelData || {}); if (!filename) { this.showNotification?.('No model filename available', 'error'); return; } dialog._infoDialogCivitaiFetchStarted = true; const defaultLabel = forceRefresh ? 'Refetch metadata' : 'Fetch metadata'; const setFetchingState = (isFetching, label = defaultLabel) => { if (!button) return; button.dataset.fetchingCivitai = isFetching ? 'true' : 'false'; button.disabled = isFetching; button.innerHTML = isFetching ? `${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')} Fetching...` : `${getSvgIcon('globe', 'currentColor', 'mr-info-external-link-icon')} ${this.escapeHtml(label)}`; }; setFetchingState(true); try { let hash = this.getInfoDialogHash(data); if (!hash) { const hashButton = dialog.querySelector('.mr-info-hash-calculate'); const hashResult = await this.calculateInfoDialogHash(dialog, hashButton); if (!hashResult) { setFetchingState(false); return; } data = dialog._infoDialogData || data; hash = this.getInfoDialogHash(data); } if (!hash) { throw new Error('No SHA256 hash available'); } const tokens = this.getStoredTokens?.() || {}; const result = await this.fetchJson('/model_resolver/civitai-search', { method: 'POST', body: JSON.stringify({ filename, category, resolved_path: resolvedPath, sha256: hash, force_refresh: forceRefresh, hf_token: tokens.hf_token || '', brave_search_api_key: tokens.brave_search_api_key || '', hf_use_brave_fallback: tokens.hf_use_brave_fallback !== false }) }, 'Fetch model metadata'); const merged = { ...data, ...result, filename, category, file_path: result.file_path || data.file_path || resolvedPath, resolved_path: result.resolved_path || data.resolved_path || resolvedPath, sha256: result.sha256 || hash, metadata_checked: true, civitai_checked: true }; dialog._infoDialogData = merged; this.updateInfoDialogWithData(dialog, merged); if (merged.url || merged.version_url) { this.showNotification?.( forceRefresh ? 'Metadata refreshed.' : 'Metadata loaded.', 'success' ); } else { this.showNotification?.('No exact remote metadata match found.', 'info'); } } catch (error) { this.showNotification?.(`Metadata fetch failed: ${error?.message || error}`, 'error'); this.updateInfoDialogWithData(dialog, { ...(dialog._infoDialogData || data), metadata_checked: true, civitai_checked: true }); } finally { setFetchingState(false); } }, getInfoMetadataSourceKey(data = {}) { const source = String(data.details_source || data.source || '').toLowerCase(); const url = String(data.version_url || data.url || data.platform_url || '').toLowerCase(); if (source.includes('huggingface') || url.includes('huggingface.co')) return 'huggingface'; if (source.includes('civarchive') || url.includes('civarchive.com')) return 'civarchive'; if (source.includes('civitai') || url.includes('civitai.com') || url.includes('civitai.red')) return 'civitai'; return 'metadata'; }, getInfoMetadataSourceLabel(data = {}) { const key = this.getInfoMetadataSourceKey(data); if (key === 'huggingface') return 'HuggingFace'; if (key === 'civarchive') return 'CivArchive'; if (key === 'civitai') return 'CivitAI'; return 'metadata source'; }, getInfoMetadataSourceIcon(data = {}) { const key = this.getInfoMetadataSourceKey(data); if (key === 'huggingface') return getSvgIcon('huggingface', 'currentColor', 'mr-info-external-link-icon'); if (key === 'civitai') return getSvgIcon('civitai', 'currentColor', 'mr-info-external-link-icon'); return getSvgIcon('externalLink', 'currentColor', 'mr-info-external-link-icon'); }, hasInfoMetadataBeenChecked(data = {}) { return Boolean(data.metadata_checked || data.metadataChecked || data.civitai_checked || data.civitaiChecked); }, /** * Update the info dialog with data */ updateInfoDialogWithData(dialog, data) { const loadingDiv = dialog.querySelector('.mr-info-dialog-loading'); const bodyDiv = dialog.querySelector('.mr-info-dialog-body'); this.bindInfoDialogInteractions(dialog); if (loadingDiv) loadingDiv.style.display = 'none'; if (bodyDiv) bodyDiv.style.display = 'block'; if (!data) { this.updateInfoDialogError(dialog, 'No data received'); return; } dialog._infoDialogData = { ...(dialog._infoDialogData || {}), ...data }; // Update title const titleEl = dialog.querySelector('.mr-info-dialog-title'); if (titleEl) { const modelName = data.model_name || data.modelName || data.name || data.filename || 'Unknown Model'; const versionName = data.version_name || data.versionName || ''; titleEl.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(modelName); } // Update type tag const typeTag = dialog.querySelector('.mr-info-type'); if (typeTag) { const modelType = data.model_type || data.modelType || ''; typeTag.textContent = modelType.toUpperCase(); typeTag.className = `mr-info-tag mr-info-type ${this.getModelTypeColorClass(modelType)}`; } // Update base model tag const baseModelTag = dialog.querySelector('.mr-info-basemodel'); if (baseModelTag) { const baseModel = data.base_model || data.baseModel || ''; baseModelTag.textContent = baseModel || ''; if (baseModel) { baseModelTag.style.display = ''; baseModelTag.className = `mr-info-tag mr-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`; } else { baseModelTag.style.display = 'none'; } } // Update top tags const tagsRowEl = dialog.querySelector('.mr-info-tags-row'); const tagsEl = dialog.querySelector('.mr-info-tags'); if (tagsRowEl && tagsEl) { const tags = this.getInfoDialogTags(data); if (tags.length > 0) { tagsEl.innerHTML = tags.map(tag => ` ${this.escapeHtml(tag)} `).join(''); this.setInfoElementVisible(tagsRowEl, true); this.bindTooltips(tagsEl); } else { tagsEl.innerHTML = ''; this.setInfoElementVisible(tagsRowEl, false); } } // Update file const fileEl = dialog.querySelector('.mr-info-file'); if (fileEl && data.filename) { fileEl.textContent = data.filename; } // Update location const locationEl = dialog.querySelector('.mr-info-location'); if (locationEl) { const location = this.formatInfoDialogLocation(data); const openPath = this.getInfoDialogLocationOpenPath(data) || location; locationEl.innerHTML = location ? ` ` : ''; this.setInfoTableRowVisible(locationEl.closest('tr'), Boolean(location)); if (location) this.bindTooltips(locationEl); } // Update hash this.updateInfoDialogHashDisplay(dialog, data); // Update CivitAI link const civitaiLinkEl = dialog.querySelector('.mr-info-civitai-link'); if (civitaiLinkEl) { const hasLocalFile = Boolean(this.getInfoDialogModelFilePath(data)); if (data.url || data.version_url) { const url = data.version_url || data.url; const sourceLabel = this.getInfoMetadataSourceLabel(data); civitaiLinkEl.innerHTML = ` View on ${this.escapeHtml(sourceLabel)} ${this.getInfoMetadataSourceIcon(data)} ${hasLocalFile ? ` ` : ''} `; } else if (this.hasInfoMetadataBeenChecked(data)) { civitaiLinkEl.innerHTML = ` No exact metadata match ${hasLocalFile ? ` ` : ''} `; } else if (hasLocalFile) { civitaiLinkEl.innerHTML = ` `; } else { civitaiLinkEl.innerHTML = ''; } } // Update base model row const baseModelRowEl = dialog.querySelector('.mr-info-base-model'); if (baseModelRowEl) { const baseModel = data.base_model || data.baseModel || ''; baseModelRowEl.textContent = baseModel; const row = baseModelRowEl.closest('tr'); this.setInfoTableRowVisible(row, Boolean(baseModel)); } // Update file size row const sizeEl = dialog.querySelector('.mr-info-size'); if (sizeEl) { const sizeLabel = this.formatInfoDialogSize(data); sizeEl.textContent = sizeLabel; this.setInfoTableRowVisible(sizeEl.closest('tr'), Boolean(sizeLabel)); } // Update trained words const trainedWordsEl = dialog.querySelector('.mr-info-trained-words'); if (trainedWordsEl) { const words = this.getInfoDialogTrainedWords(data); const row = trainedWordsEl.closest('tr'); if (words.length > 0) { dialog._selectedTrainedWords = new Set(); trainedWordsEl.innerHTML = `
${words.map(word => ` `).join('')}
`; this.setInfoTableRowVisible(row, true); this.bindTooltips(trainedWordsEl); this.updateSelectedTrainedWordsSummary(dialog); } else { this.setInfoTableRowVisible(row, false); } } // Update clip skip const clipSkipEl = dialog.querySelector('.mr-info-clip-skip'); if (clipSkipEl) { const clipSkip = data.clip_skip || data.clipSkip; const row = clipSkipEl.closest('tr'); if (clipSkip && clipSkip !== 'None') { clipSkipEl.textContent = clipSkip; this.setInfoTableRowVisible(row, true); } else { this.setInfoTableRowVisible(row, false); } } // Update description const descEl = dialog.querySelector('.mr-info-description'); if (descEl) { const desc = data.description || data.model_description || data.modelDescription || ''; if (desc) { const actionsEl = dialog.querySelector('.mr-info-description-actions'); const toggleBtn = dialog.querySelector('.mr-info-description-toggle'); let sanitizedHtml = ''; try { sanitizedHtml = this.sanitizeDescriptionHtml(desc); } catch (error) { console.error('Model Resolver: Failed to sanitize description HTML:', error); } const fallbackText = this.escapeHtml(String(desc).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `

${fallbackText}

`; const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); descEl.innerHTML = finalHtml; descEl.classList.remove('is-expanded'); const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900; this.setInfoElementVisible(actionsEl, shouldCollapse); if (toggleBtn) { toggleBtn.textContent = 'Show more'; } if (!shouldCollapse) { descEl.classList.add('is-expanded'); } const row = descEl.closest('tr'); this.setInfoTableRowVisible(row, true); } else { const row = descEl.closest('tr'); this.setInfoTableRowVisible(row, false); } } // Update images this.updateInfoDialogImages(dialog, data.images || data.modelImages || []); }, /** * Update images in the info dialog */ updateInfoDialogImages(dialog, images) { const imagesContainer = dialog.querySelector('.mr-info-images'); if (!imagesContainer) return; if (!Array.isArray(images) || !images.length) { dialog._infoImages = []; imagesContainer.innerHTML = ''; return; } const visibleImages = images.slice(0, 8).filter(img => img?.url); dialog._infoImages = visibleImages; const renderImageCard = (img, index) => { const captionParts = []; if (img.seed) captionParts.push(` ${this.escapeHtml(img.seed)}`); if (img.steps) captionParts.push(` ${this.escapeHtml(img.steps)}`); if (img.cfg) captionParts.push(` ${this.escapeHtml(img.cfg)}`); if (img.sampler) captionParts.push(` ${this.escapeHtml(img.sampler)}`); if (img.model) captionParts.push(` ${this.escapeHtml(this.truncateText(img.model, 72))}`); if (img.positive) captionParts.push(` ${this.escapeHtml(this.truncateText(img.positive, 180))}`); if (img.negative) captionParts.push(` ${this.escapeHtml(this.truncateText(img.negative, 180))}`); return `
${img.civitaiUrl ? ` ${getSvgIcon('civitai', 'currentColor', 'mr-info-image-civitai-icon')} CivitAI ` : ''}
${captionParts.join('')}
`; }; let imagesHtml = '
Example Images
'; imagesHtml += visibleImages.map(renderImageCard).join(''); imagesHtml += '
'; imagesContainer.innerHTML = imagesHtml; imagesContainer.querySelectorAll('.mr-info-image-preview-btn').forEach(button => { button.addEventListener('click', () => { const index = parseInt(button.dataset.imageIndex || '0', 10); this.openInfoImagePreview(visibleImages, Number.isNaN(index) ? 0 : index); }); }); this.bindTooltips(imagesContainer); }, getInfoImageMetadataRows(image = {}) { const metadata = image.metadata && typeof image.metadata === 'object' ? image.metadata : {}; const width = image.width || metadata.width || ''; const height = image.height || metadata.height || ''; const cfg = image.cfg || image.cfgScale || metadata.cfg || metadata.cfgScale || ''; const denoise = image.denoise || metadata.denoise || ''; const scheduler = image.scheduler || metadata.scheduler || ''; const rows = [ ['Seed', image.seed], ['Steps', image.steps], ['Width', width], ['Height', height], ['CFG Scale', cfg], ['Denoise', denoise], ['Sampler', image.sampler], ['Scheduler', scheduler], ['Clip skip', image.clip_skip], ['Model', image.model] ]; return rows.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== ''); }, getInfoImageResources(image = {}) { const metadata = image.metadata && typeof image.metadata === 'object' ? image.metadata : {}; const resources = Array.isArray(image.resources) ? image.resources : Array.isArray(image.additionalResources) ? image.additionalResources : Array.isArray(metadata.additionalResources) ? metadata.additionalResources : Array.isArray(metadata.resources) ? metadata.resources : []; return resources .filter(resource => resource && typeof resource === 'object') .map(resource => { const modelId = resource.modelId || resource.model_id; const versionId = resource.modelVersionId || resource.versionId || resource.model_version_id; const url = resource.url || resource.modelUrl || (modelId ? `https://civitai.com/models/${modelId}${versionId ? `?modelVersionId=${versionId}` : ''}` : ''); return { name: resource.name || resource.modelName || resource.model || resource.hash || 'Resource', version: resource.versionName || resource.modelVersionName || resource.version || '', type: resource.type || resource.modelType || resource.resourceType || '', weight: resource.weight || resource.strength || resource.value || '', url }; }) .filter(resource => resource.name); }, renderInfoImagePreviewPrompt(label, value, copyKey) { const text = String(value || '').trim(); if (!text) return ''; return `

${this.escapeHtml(label)}

${this.escapeHtml(text)}

`; }, renderInfoImagePreviewContent(preview) { const images = Array.isArray(preview?._images) ? preview._images : []; const index = Math.max(0, Math.min(images.length - 1, preview?._imageIndex || 0)); const image = images[index] || {}; const metadataRows = this.getInfoImageMetadataRows(image); const resources = this.getInfoImageResources(image); const hasPrevious = images.length > 1; const positionText = images.length > 1 ? `${index + 1} / ${images.length}` : '1 image'; const metadataHtml = metadataRows.length ? metadataRows.map(([label, value]) => `
${this.escapeHtml(label)} ${this.escapeHtml(value)}
`).join('') : '
No generation metadata available.
'; const resourcesHtml = resources.length ? resources.map(resource => `
${resource.url ? `${this.escapeHtml(resource.name)}` : `${this.escapeHtml(resource.name)}` }
${resource.type ? `${this.escapeHtml(resource.type)}` : ''} ${resource.weight !== '' ? `${this.escapeHtml(resource.weight)}` : ''}
${resource.version ? (resource.url ? `${this.escapeHtml(resource.version)}` : `
${this.escapeHtml(resource.version)}
` ) : ''}
`).join('') : ''; return ` `; }, openInfoImagePreview(images, startIndex = 0) { const imageList = (Array.isArray(images) ? images : []).filter(img => img?.url); if (!imageList.length) return; this.closeInfoImagePreview(); const preview = document.createElement('div'); preview.className = 'mr-image-preview-backdrop'; preview._images = imageList; preview._imageIndex = Math.max(0, Math.min(imageList.length - 1, startIndex)); preview._onKeyDown = (event) => { if (event.key === 'Escape') { this.closeInfoImagePreview(preview); } else if (event.key === 'ArrowLeft') { this.showInfoImagePreviewAt(preview, preview._imageIndex - 1); } else if (event.key === 'ArrowRight') { this.showInfoImagePreviewAt(preview, preview._imageIndex + 1); } }; preview.addEventListener('click', async (event) => { if (event.target === preview) { this.closeInfoImagePreview(preview); return; } const actionEl = event.target.closest('[data-action]'); if (actionEl && preview.contains(actionEl)) { const action = actionEl.dataset.action; if (action === 'close') { this.closeInfoImagePreview(preview); } else if (action === 'previous') { this.showInfoImagePreviewAt(preview, preview._imageIndex - 1); } else if (action === 'next') { this.showInfoImagePreviewAt(preview, preview._imageIndex + 1); } return; } const copyBtn = event.target.closest('.mr-image-preview-copy'); if (copyBtn && preview.contains(copyBtn)) { await this.copyInfoImagePreviewText(preview, copyBtn); } }); document.body.appendChild(preview); document.addEventListener('keydown', preview._onKeyDown); this.updateInfoImagePreview(preview); }, showInfoImagePreviewAt(preview, index) { const images = Array.isArray(preview?._images) ? preview._images : []; if (!preview || !images.length) return; preview._imageIndex = (index + images.length) % images.length; this.updateInfoImagePreview(preview); }, updateInfoImagePreview(preview) { if (!preview) return; preview.innerHTML = this.renderInfoImagePreviewContent(preview); this.bindTooltips(preview); }, async copyInfoImagePreviewText(preview, button) { const images = Array.isArray(preview?._images) ? preview._images : []; const image = images[preview?._imageIndex || 0] || {}; const key = button?.dataset?.copyKey || ''; const metadataRows = this.getInfoImageMetadataRows(image); const resources = this.getInfoImageResources(image); const text = key === 'positive' ? image.positive : key === 'negative' ? image.negative : [ image.positive ? `Prompt: ${image.positive}` : '', image.negative ? `Negative prompt: ${image.negative}` : '', ...metadataRows.map(([label, value]) => `${label}: ${value}`), ...resources.map(resource => `Resource: ${[ resource.name, resource.version, resource.type, resource.weight !== '' ? `strength ${resource.weight}` : '' ].filter(Boolean).join(' / ')}`) ].filter(Boolean).join('\n'); if (!text) return; try { await navigator.clipboard.writeText(String(text)); button.classList.add('is-copied'); button.innerHTML = `${getSvgIcon('copy')} Copied`; } catch (error) { console.error('Model Resolver: Failed to copy image metadata:', error); button.innerHTML = `${getSvgIcon('copy')} Failed`; } setTimeout(() => { if (button.isConnected) { button.classList.remove('is-copied'); button.innerHTML = `${getSvgIcon('copy')} Copy${key === 'all' ? ' all' : ''}`; } }, 1200); }, closeInfoImagePreview(preview = null) { const target = preview || document.querySelector('.mr-image-preview-backdrop'); if (!target) return; if (target._onKeyDown) { document.removeEventListener('keydown', target._onKeyDown); target._onKeyDown = null; } target.remove(); }, async showSourceModelDetails(model = {}) { if (!this.canShowSourceDetails(model)) { this.showNotification?.('No detailed source page is available for this model.', 'info'); return; } this.closeSourceModelDetails(); const details = document.createElement('div'); details.className = 'mr-model-details-backdrop'; details._sourceModel = model; details.innerHTML = this.renderSourceModelDetailsLoading(model); document.body.appendChild(details); this.bindSourceModelDetailsEvents(details); try { const tokens = this.getStoredTokens?.() || {}; const data = await this.fetchJson('/model_resolver/model-details', { method: 'POST', body: JSON.stringify({ source: model.details_source || model.source, model_id: model.model_id || model.modelId, version_id: model.version_id || model.versionId, civitai_key: tokens.civitai_key || '' }) }, 'Fetch model details'); details._detailsData = data; details._selectedVersionId = String(data.selected_version?.id || data.version_id || ''); details.innerHTML = this.renderSourceModelDetails(data, model); this.bindSourceModelDetailsEvents(details); this.bindTooltips(details); } catch (error) { console.error('Model Resolver: model details error:', error); details.innerHTML = this.renderSourceModelDetailsError(error); this.bindSourceModelDetailsEvents(details); } }, closeSourceModelDetails() { document.querySelectorAll('.mr-model-details-backdrop').forEach(element => element.remove()); }, renderSourceModelDetailsLoading(model = {}) { const title = model.name || model.model_name || model.filename || 'Model details'; return `

${this.escapeHtml(title)}

${this.escapeHtml(model.details_source || model.source || '')}
Loading model details...
`; }, renderSourceModelDetailsError(error) { return `

Model details

Error
${this.escapeHtml(error?.message || 'Failed to load model details.')}
`; }, async fetchSourceModelDetailsVersion(details, versionId) { if (!details?._detailsData || !versionId) return; try { const data = details._detailsData; const tokens = this.getStoredTokens?.() || {}; const fresh = await this.fetchJson('/model_resolver/model-details', { method: 'POST', body: JSON.stringify({ source: data.source || details._sourceModel?.details_source || details._sourceModel?.source, model_id: data.model_id || details._sourceModel?.model_id || details._sourceModel?.modelId, version_id: versionId, civitai_key: tokens.civitai_key || '' }) }, 'Fetch model version details'); const freshVersion = fresh.selected_version; if (!freshVersion?.id) return; data.versions = (data.versions || []).map(version => String(version.id) === String(freshVersion.id) ? { ...version, ...freshVersion } : version ); data.selected_version = data.versions.find(version => String(version.id) === String(freshVersion.id)) || freshVersion; if (String(details._selectedVersionId || '') !== String(freshVersion.id)) return; details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {}); this.bindTooltips(details); } catch (error) { console.error('Model Resolver: failed to fetch source model version details:', error); this.showNotification?.('Failed to load this model version.', 'error'); } }, renderSourceModelDetails(data = {}, contextModel = {}) { const versions = Array.isArray(data.versions) ? data.versions : []; const selectedVersionId = String(data.selected_version?.id || data.version_id || versions[0]?.id || ''); const selectedVersion = versions.find(version => String(version.id) === selectedVersionId) || data.selected_version || versions[0] || {}; const images = (Array.isArray(selectedVersion.images) && selectedVersion.images.length ? selectedVersion.images : data.images || []).filter(img => img?.url).slice(0, 12); const stats = data.stats || {}; const versionStats = selectedVersion.stats || {}; const tags = Array.isArray(data.tags) ? data.tags.slice(0, 12) : []; const trainedWords = this.normalizeTrainedWords(selectedVersion.trained_words || []); const description = data.description || selectedVersion.description || ''; const sanitizedDescription = description ? this.sanitizeDescriptionHtml(description) : ''; const source = data.source || contextModel.details_source || contextModel.source || ''; const creator = data.creator || {}; const creatorName = creator.username || creator.name || ''; const pageUrl = data.version_url || data.url || ''; const civitaiUrl = this.getSourceModelCivitaiUrl(data, selectedVersion, contextModel); const showCivitaiAction = civitaiUrl && civitaiUrl !== pageUrl; const statItems = [ ['Downloads', stats.downloadCount || stats.downloads || versionStats.downloadCount || versionStats.downloads], ['Thumbs Up', stats.thumbsUpCount || versionStats.thumbsUpCount], ['Rating', stats.rating || versionStats.rating], ['Published', selectedVersion.published_at ? this.formatDetailsDate(selectedVersion.published_at) : ''] ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ''); return `

${this.escapeHtml(data.name || contextModel.name || 'Model')}

${this.escapeHtml([source, data.type, creatorName].filter(Boolean).join(' / '))}
${showCivitaiAction ? `${getSvgIcon('civitai')} View on CivitAI` : ''} ${pageUrl ? `${getSvgIcon('externalLink')} Open page` : ''}
${versions.map(version => ` `).join('')}

About

${sanitizedDescription ? sanitizedDescription : '

No description available.

'}
`; }, getSourceModelCivitaiUrl(data = {}, selectedVersion = {}, contextModel = {}) { const modelId = selectedVersion.civitai_model_id || selectedVersion.civitaiModelId || data.civitai_model_id || data.civitaiModelId || contextModel.civitai_model_id || contextModel.civitaiModelId; const versionId = selectedVersion.civitai_model_version_id || selectedVersion.civitaiModelVersionId || data.civitai_model_version_id || data.civitaiModelVersionId || contextModel.civitai_model_version_id || contextModel.civitaiModelVersionId; if (modelId) { return `https://civitai.com/models/${encodeURIComponent(modelId)}${versionId ? `?modelVersionId=${encodeURIComponent(versionId)}` : ''}`; } const explicitUrl = selectedVersion.platform_url || selectedVersion.platformUrl || data.platform_url || data.platformUrl || contextModel.platform_url || contextModel.platformUrl; const value = String(explicitUrl || '').trim(); if (!/^https?:\/\//i.test(value)) return ''; try { const url = new URL(value); const host = url.hostname.toLowerCase().replace(/^www\./, ''); if (host !== 'civitai.com' && host !== 'civitai.red') return ''; url.hostname = 'civitai.com'; return url.toString(); } catch (error) { return /civitai\.(?:com|red)\//i.test(value) ? value.replace('civitai.red', 'civitai.com') : ''; } }, renderSourceModelDetailsFiles(version = {}, data = {}, contextModel = {}) { const files = Array.isArray(version.files) ? version.files.filter(file => file?.download_url) : []; if (!files.length) { return '
No downloadable files available for this version.
'; } const targetIndex = files.findIndex(file => this.isSourceModelDetailsTargetFile(file, contextModel)); const orderedFiles = targetIndex > 0 ? [files[targetIndex], ...files.filter((_, index) => index !== targetIndex)] : files; return orderedFiles.map((file, index) => { const isTarget = this.isSourceModelDetailsTargetFile(file, contextModel); const fileMeta = this.getSourceModelFileMeta(file); const mirrors = this.getSourceModelMirrors(file); const preferredMirror = this.getSourceModelPreferredMirror(file, contextModel, mirrors); const preferredDownloadUrl = preferredMirror?.url || file.download_url; const preferredFilename = preferredMirror?.filename || file.name || contextModel.filename || data.name || 'model'; const payload = { source: data.source || contextModel.details_source || contextModel.source, model_id: data.model_id || contextModel.model_id, version_id: version.id || data.version_id || contextModel.version_id, name: data.name || contextModel.name, version_name: version.name || '', type: data.type || contextModel.type, filename: preferredFilename, download_url: preferredDownloadUrl, url: version.url || data.version_url || data.url, size: file.size, base_model: version.base_model || contextModel.base_model, tags: data.tags || [], match_type: 'selected', confidence: 100 }; const mirrorList = mirrors.length > 1 ? this.renderSourceModelMirrors(mirrors, payload) : ''; const summaryBadges = [ isTarget ? 'Matched' : '', file.primary ? 'Primary' : '', mirrors.length ? `${mirrors.length} mirrors` : '' ].filter(Boolean); const openAttr = index === 0 ? ' open' : ''; return `
${this.escapeHtml(file.name || `File ${index + 1}`)} ${this.escapeHtml(fileMeta.summary)} ${summaryBadges.length ? `${summaryBadges.map(badge => `${this.escapeHtml(badge)}`).join('')}` : ''}
${fileMeta.badges.length ? `
${fileMeta.badges.map(badge => `${this.escapeHtml(badge)}`).join('')}
` : ''} ${mirrorList}
`; }).join(''); }, isSourceModelDetailsTargetFile(file = {}, contextModel = {}) { const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || ''); const targetUrl = String(contextModel.download_url || contextModel.url || '').trim(); const targetHash = String(contextModel.sha256 || contextModel.hash || '').trim().toLowerCase(); const fileHash = this.getSourceModelFileHash(file); if (targetHash && fileHash && targetHash === fileHash) return true; const urls = this.getSourceModelFileUrls(file); if (targetUrl && urls.some(url => url === targetUrl)) return true; if (!targetFilename) return false; return this.getSourceModelFileNames(file).some(name => ( this.getSourceModelComparableFilename(name) === targetFilename )); }, getSourceModelPreferredMirror(file = {}, contextModel = {}, mirrors = null) { const candidates = Array.isArray(mirrors) ? mirrors : this.getSourceModelMirrors(file); const targetUrl = String(contextModel.download_url || contextModel.url || '').trim(); if (targetUrl) { const byUrl = candidates.find(mirror => String(mirror.url || '') === targetUrl); if (byUrl) return byUrl; } const targetFilename = this.getSourceModelComparableFilename(contextModel.filename || contextModel.original_path || ''); if (targetFilename) { const byName = candidates.find(mirror => ( this.getSourceModelComparableFilename(mirror.filename || mirror.name || '') === targetFilename )); if (byName) return byName; } return null; }, getSourceModelComparableFilename(value = '') { return String(value || '') .split(/[\\/]+/) .pop() .trim() .toLowerCase(); }, getSourceModelFileHash(file = {}) { const hashes = file.hashes && typeof file.hashes === 'object' ? file.hashes : {}; return String(file.sha256 || file.hash || hashes.SHA256 || hashes.sha256 || '').trim().toLowerCase(); }, getSourceModelFileNames(file = {}) { const names = new Set(); [file.name, file.filename].forEach(name => { if (name) names.add(String(name)); }); const mirrors = Array.isArray(file.mirrors) ? file.mirrors : []; mirrors.forEach(mirror => { if (mirror?.filename) names.add(String(mirror.filename)); if (mirror?.name) names.add(String(mirror.name)); }); return Array.from(names); }, getSourceModelFileUrls(file = {}) { const urls = new Set(); [file.download_url, file.downloadUrl, file.url].forEach(url => { if (url) urls.add(String(url)); }); const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls : []; downloadUrls.forEach(url => { if (url) urls.add(String(url)); }); const mirrors = Array.isArray(file.mirrors) ? file.mirrors : []; mirrors.forEach(mirror => { if (mirror?.url) urls.add(String(mirror.url)); }); return Array.from(urls); }, getSourceModelMirrors(file = {}) { const mirrors = Array.isArray(file.mirrors) ? file.mirrors.filter(mirror => mirror?.url) : []; const downloadUrls = Array.isArray(file.download_urls) ? file.download_urls.filter(Boolean) : []; const seen = new Set(); const normalized = []; for (const mirror of mirrors) { const url = String(mirror.url || ''); if (!url || seen.has(url)) continue; seen.add(url); normalized.push({ ...mirror, url, filename: mirror.filename || file.name, is_dead: Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt) }); } for (const url of downloadUrls) { const value = String(url || ''); if (!value || seen.has(value)) continue; seen.add(value); normalized.push({ url: value, filename: file.name, source: this.getSourceModelMirrorHost(value), is_dead: false }); } return normalized; }, renderSourceModelMirrors(mirrors = [], basePayload = {}) { if (!mirrors.length) return ''; const groups = this.groupSourceModelMirrors(mirrors); return `
Mirrors
${groups.map(group => `
${this.getSourceModelMirrorIcon(group.label)} ${this.escapeHtml(group.label)} (${group.items.length} ${group.items.length === 1 ? 'mirror' : 'mirrors'})
${group.items.map((mirror) => { const payload = { ...basePayload, download_url: mirror.url, filename: mirror.filename || basePayload.filename, mirror_source: mirror.source || this.getSourceModelMirrorHost(mirror.url), mirror_index: mirror._index }; const meta = this.getSourceModelMirrorMeta(mirror, mirror._index === 0); const isDead = this.isSourceModelMirrorDead(mirror); const isGated = this.isSourceModelMirrorGated(mirror); const title = isDead ? 'Likely dead link' : (isGated ? 'Gated Download' : 'Download file'); const actionIcon = getSvgIcon(isDead ? 'skull' : (isGated ? 'shield' : 'download')); const rowClass = [ isDead ? 'is-dead' : '', isGated && !isDead ? 'is-gated' : '' ].filter(Boolean).map(value => ` ${value}`).join(''); const payloadData = this.escapeHtml(encodeURIComponent(JSON.stringify(payload))); return ` `; }).join('')}
`).join('')}
`; }, groupSourceModelMirrors(mirrors = []) { const groups = new Map(); mirrors.forEach((mirror, index) => { const label = this.getSourceModelMirrorSourceLabel(mirror, index); if (!groups.has(label)) { groups.set(label, { label, items: [] }); } groups.get(label).items.push({ ...mirror, _index: index }); }); return Array.from(groups.values()); }, getSourceModelMirrorIcon(label = '') { const normalized = String(label || '').toLowerCase(); if (normalized.includes('huggingface')) return getSvgIcon('huggingface'); if (normalized.includes('civitai')) return getSvgIcon('civitai'); return getSvgIcon('globe'); }, isSourceModelMirrorDead(mirror = {}) { return Boolean(mirror.is_dead || mirror.isDead || mirror.deleted_at || mirror.deletedAt); }, isSourceModelMirrorGated(mirror = {}) { return Boolean(mirror.is_gated || mirror.isGated || mirror.gated); }, getSourceModelMirrorHost(url = '') { try { return new URL(url).hostname.replace(/^www\./, ''); } catch (error) { return ''; } }, getSourceModelMirrorSourceLabel(mirror = {}, index = 0) { const source = String(mirror.source || '').trim(); if (source) return source; const host = this.getSourceModelMirrorHost(mirror.url || ''); if (host.includes('huggingface.co')) return 'HuggingFace'; if (host.includes('civitai.com')) return 'CivitAI'; return host || `Mirror ${index + 1}`; }, getSourceModelMirrorMeta(mirror = {}, isDefault = false) { const host = this.getSourceModelMirrorHost(mirror.url || ''); const mirrorHash = this.getSourceModelDisplayHash(mirror); const meta = [ isDefault ? 'Default' : '', this.isSourceModelMirrorDead(mirror) ? 'Likely dead' : '', host, this.isSourceModelMirrorGated(mirror) ? 'Gated' : '', mirror.is_paid ? 'Paid' : '', mirrorHash ? `SHA256 ${mirrorHash}` : '' ].filter(Boolean); return meta.join(' / '); }, getSourceModelFileMeta(file = {}) { const metadata = file.metadata && typeof file.metadata === 'object' ? file.metadata : {}; const mirrorCount = Array.isArray(file.mirrors) && file.mirrors.length ? file.mirrors.length : file.mirror_count; const badges = [ file.primary ? 'Primary' : '', file.type || '', metadata.format || '', metadata.size || '', metadata.fp || '', mirrorCount ? `${mirrorCount} mirrors` : '', file.id ? `ID ${file.id}` : '' ].filter(Boolean); const fileHash = this.getSourceModelDisplayHash(file); const summary = [ this.formatSearchResultSize(file), fileHash ? `SHA256 ${fileHash}` : '' ].filter(Boolean).join(' / ') || 'Downloadable file'; return { badges, summary }; }, getSourceModelDisplayHash(value = {}) { const hashes = value.hashes && typeof value.hashes === 'object' ? value.hashes : {}; return String(value.sha256 || value.hash || hashes.SHA256 || hashes.sha256 || '').trim(); }, renderSourceModelImageMeta(image = {}) { const stats = image.stats || {}; const metaItems = [ ['Like', image.likeCount || image.likes || stats.likeCount || stats.likes], ['Love', image.heartCount || image.reactions || stats.heartCount], ['Comments', image.commentCount || stats.commentCount], ['Buzz', image.buzzCount || stats.buzzCount] ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ''); if (!metaItems.length) return ''; return ` ${metaItems.map(([label, value]) => `${this.escapeHtml(label)} ${this.escapeHtml(value)}`).join('')} `; }, getSourceModelImageSizingStyle(image = {}) { const width = Number(image.width || image.metadata?.width || 0); const height = Number(image.height || image.metadata?.height || 0); const ratio = width > 0 && height > 0 ? width / height : 0.72; const clampedRatio = Math.max(0.48, Math.min(1.85, ratio)); const preferredHeight = Math.max( 320, Math.min( 560, Math.round(430 + Math.max(0, 1 - clampedRatio) * 150 - Math.max(0, clampedRatio - 1) * 70) ) ); let preferredWidth = Math.round(preferredHeight * clampedRatio); if (preferredWidth < 220) { preferredWidth = 220; } else if (preferredWidth > 640) { preferredWidth = 640; } const adjustedHeight = Math.round(preferredWidth / clampedRatio); return `--mr-image-ratio:${clampedRatio.toFixed(3)}; --mr-image-width:${preferredWidth}px; --mr-image-height:${adjustedHeight}px;`; }, bindSourceModelDetailsEvents(details) { if (!details) return; if (details.dataset.modelDetailsBound === 'true') return; details.dataset.modelDetailsBound = 'true'; details.addEventListener('click', (event) => { if (event.target === details) { this.closeSourceModelDetails(); return; } const actionEl = event.target.closest('[data-action]'); if (actionEl?.dataset.action === 'close') { this.closeSourceModelDetails(); return; } const versionTab = event.target.closest('.mr-model-details-version-tab'); if (versionTab && details.contains(versionTab)) { details._selectedVersionId = String(versionTab.dataset.versionId || ''); const data = details._detailsData; if (data) { const selected = (data.versions || []).find(version => String(version.id) === details._selectedVersionId) || data.selected_version; data.selected_version = selected; details.innerHTML = this.renderSourceModelDetails(data, details._sourceModel || {}); this.bindTooltips(details); if (selected?.id && (!Array.isArray(selected.files) || !selected.files.length)) { this.fetchSourceModelDetailsVersion(details, selected.id); } } return; } const galleryNav = event.target.closest('.mr-model-details-gallery-nav'); if (galleryNav && details.contains(galleryNav)) { const strip = galleryNav.closest('.mr-model-details-gallery')?.querySelector('.mr-model-details-gallery-strip'); if (!strip) return; const direction = Number(galleryNav.dataset.galleryDirection || 1); strip.scrollBy({ left: direction * Math.max(260, Math.round(strip.clientWidth * 0.82)), behavior: 'smooth' }); return; } const imageBtn = event.target.closest('.mr-model-details-image'); if (imageBtn && details.contains(imageBtn)) { if (details._detailsGalleryDraggedUntil && Date.now() < details._detailsGalleryDraggedUntil) { return; } const data = details._detailsData || {}; const version = data.selected_version || {}; const images = (Array.isArray(version.images) && version.images.length ? version.images : data.images || []).filter(img => img?.url); const index = parseInt(imageBtn.dataset.imageIndex || '0', 10); this.openInfoImagePreview(images, Number.isNaN(index) ? 0 : index); return; } const useBtn = event.target.closest('.mr-model-details-use'); if (useBtn && details.contains(useBtn)) { try { const selection = JSON.parse(decodeURIComponent(useBtn.dataset.selection || '')); this.applySourceModelDetailsSelection(selection, details._sourceModel || {}); this.closeSourceModelDetails(); } catch (error) { console.error('Model Resolver: failed to apply model detail selection:', error); this.showNotification?.('Failed to select this model version.', 'error'); } } }); details.addEventListener('pointerdown', (event) => { const strip = event.target.closest?.('.mr-model-details-gallery-strip'); if (!strip || !details.contains(strip) || event.button > 0) return; const startX = event.clientX; const startScrollLeft = strip.scrollLeft; let moved = false; strip.classList.add('is-dragging'); strip.setPointerCapture?.(event.pointerId); const onPointerMove = (moveEvent) => { const delta = moveEvent.clientX - startX; if (Math.abs(delta) > 4) { moved = true; details._detailsGalleryDraggedUntil = Date.now() + 250; } strip.scrollLeft = startScrollLeft - delta; }; const finishDrag = () => { strip.classList.remove('is-dragging'); strip.removeEventListener('pointermove', onPointerMove); strip.removeEventListener('pointerup', finishDrag); strip.removeEventListener('pointercancel', finishDrag); if (moved) { details._detailsGalleryDraggedUntil = Date.now() + 250; } }; strip.addEventListener('pointermove', onPointerMove); strip.addEventListener('pointerup', finishDrag, { once: true }); strip.addEventListener('pointercancel', finishDrag, { once: true }); }); }, applySourceModelDetailsSelection(selection = {}, contextModel = {}) { const missing = this.getMissingByKey(contextModel.missing_key || contextModel.missingKey || ''); if (!missing || !selection.download_url) { this.showNotification?.('Cannot apply this version to the current missing model.', 'error'); return; } const sourceKey = String(selection.source || contextModel.details_source || contextModel.source || '').toLowerCase(); const selectedBaseModel = selection.base_model || contextModel.base_model || ''; const sourceResult = { source: sourceKey, model_id: selection.model_id, version_id: selection.version_id, name: selection.name, version_name: selection.version_name, type: selection.type, filename: selection.filename, url: selection.url, download_url: selection.download_url, size: selection.size, base_model: selectedBaseModel, tags: selection.tags || [], match_type: selection.match_type || 'selected', confidence: selection.confidence || 100, searchedAt: new Date().toISOString() }; missing.download_source = { ...sourceResult, url: sourceResult.download_url, model_url: sourceResult.url, directory: missing.category || 'checkpoints' }; const state = this.getSearchState?.(missing); if (state?.results && sourceKey) { state.results[sourceKey] = sourceResult; } missing.civitai_info = { ...(missing.civitai_info || {}), model_name: selection.name || missing.civitai_info?.model_name, version_name: selection.version_name || missing.civitai_info?.version_name, expected_filename: selection.filename || missing.civitai_info?.expected_filename, base_model: selectedBaseModel || missing.civitai_info?.base_model, tags: selection.tags || missing.civitai_info?.tags || [] }; if (selectedBaseModel) { const canonicalBaseModel = this.resolveBaseModelAlias?.(selectedBaseModel) || selectedBaseModel; if (state) { state.selectedBaseModel = canonicalBaseModel; state.lastAttemptBaseModelContext = canonicalBaseModel; } } this.refreshSearchUiForMissing?.(missing, state || null); this.refreshSearchBaseModelLabels?.(); this.updateBatchFooterButtons?.(); this.persistSearchStateForActiveWorkflow?.(); this.showNotification?.(`Selected ${selection.filename || selection.name || 'model version'}.`, 'success'); }, formatDetailsDate(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); }, /** * Update the info dialog with error */ updateInfoDialogError(dialog, message) { const civitaiLink = dialog.querySelector('.mr-info-civitai-link'); if (civitaiLink) { civitaiLink.innerHTML = `${message}`; } }, /** * Close the info dialog */ closeInfoDialog(dialog) { if (dialog?._infoDialogResizeObserver) { dialog._infoDialogResizeObserver.disconnect(); dialog._infoDialogResizeObserver = null; } if (dialog?._infoDialogResizeSaveTimer) { clearTimeout(dialog._infoDialogResizeSaveTimer); dialog._infoDialogResizeSaveTimer = null; } if (dialog?._onInfoDialogKeyDown) { document.removeEventListener('keydown', dialog._onInfoDialogKeyDown); dialog._onInfoDialogKeyDown = null; } this.saveInfoDialogSize(dialog); this.closeInfoImagePreview(); if (dialog && dialog.parentNode) { dialog.parentNode.removeChild(dialog); } } };