From 2242412b87d10208e32ac20863173ff2444d9be6 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 25 Apr 2026 00:18:54 +0200 Subject: [PATCH] Improve model info UI and sanitize descriptions Enhance the LinkerManager dialog UI and add safe HTML handling for model descriptions. This adds tooltip '?' helpers with explanatory titles for multiple info rows, wraps descriptions with a collapsible "Show more" widget, and implements sanitizeDescriptionHtml to allow a safe subset of tags/styles/links (and enforce target/rel for external links). Styling updates polish tabs, footer buttons and the description area, remove inline button sizing, and introduce a tab content animation. Also adjust click handling for description toggle and trained-words copy behavior and standardize footer button classes. --- web/linker.js | 369 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 324 insertions(+), 45 deletions(-) diff --git a/web/linker.js b/web/linker.js index 3689ce7..c795547 100644 --- a/web/linker.js +++ b/web/linker.js @@ -617,29 +617,29 @@ class LinkerManagerDialog extends ComfyDialog { - + - + - + - + - + - + - - + +
File File ?
Hash (sha256) Hash (sha256) ?
CivitAI CivitAI ?
Name Name ?
Base Model Base Model ?
@@ -688,6 +695,96 @@ class LinkerManagerDialog extends ComfyDialog { 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))]; @@ -772,6 +869,16 @@ class LinkerManagerDialog extends ComfyDialog { copyBtn.textContent = 'Copy'; } }, 1200); + return; + } + + const descToggleBtn = event.target.closest('.ml-info-description-toggle'); + if (descToggleBtn && dialog.contains(descToggleBtn)) { + const descEl = dialog.querySelector('.ml-info-description'); + if (!descEl) return; + + const isExpanded = descEl.classList.toggle('is-expanded'); + descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more'; } }); } @@ -1017,7 +1124,34 @@ class LinkerManagerDialog extends ComfyDialog { if (descEl) { const desc = data.description || data.model_description || data.modelDescription || ''; if (desc) { - descEl.textContent = desc.substring(0, 200) + (desc.length > 200 ? '...' : ''); + const actionsEl = dialog.querySelector('.ml-info-description-actions'); + const toggleBtn = dialog.querySelector('.ml-info-description-toggle'); + + let sanitizedHtml = ''; + try { + sanitizedHtml = this.sanitizeDescriptionHtml(desc); + } catch (error) { + console.error('Model Linker: 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; + if (actionsEl) { + actionsEl.style.display = shouldCollapse ? '' : 'none'; + } + if (toggleBtn) { + toggleBtn.textContent = 'Show more'; + } + if (!shouldCollapse) { + descEl.classList.add('is-expanded'); + } + const row = descEl.closest('tr'); if (row) row.style.display = ''; } else { @@ -1642,38 +1776,78 @@ class LinkerManagerDialog extends ComfyDialog { justify-content: flex-end; align-items: center; gap: 10px; + flex-wrap: wrap; padding: 16px 20px; background: linear-gradient(to top, var(--ml-bg) 0%, var(--ml-bg) 70%, transparent 100%); border-top: 1px solid var(--ml-border); } + .ml-footer-btn { + min-height: 34px; + min-width: 152px; + padding: 7px 14px; + font-size: 12px; + border-radius: 9px; + line-height: 1; + } /* Tabs */ .ml-tabs { display: flex; - gap: 4px; - padding: 0 20px; + align-items: flex-end; + gap: 8px; + padding: 10px 20px 0 20px; margin-bottom: 0; - background-color: var(--ml-bg, #222); - border-bottom: 1px solid var(--ml-border); + background: + linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); + position: relative; + z-index: 2; } .ml-tab { - padding: 12px 20px; - background: transparent; - border: none; + position: relative; + padding: 11px 18px 12px 18px; + background: linear-gradient(180deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.025) 100%); + border: 1px solid rgba(255,255,255,0.08); + border-bottom: none; + border-top-left-radius: 14px; + border-top-right-radius: 14px; color: var(--ml-text-muted, #888); font-size: 13px; - font-weight: 500; + font-weight: 600; cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.2s ease; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); + transform: translateY(6px); + opacity: 0.82; + transition: transform 0.18s ease, opacity 0.18s ease, color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; } .ml-tab:hover { color: var(--ml-text, #e0e0e0); - background: rgba(255,255,255,0.05); + background: linear-gradient(180deg, rgba(255,255,255,0.075) 0%, rgba(255,255,255,0.04) 100%); + border-color: rgba(255,255,255,0.12); + transform: translateY(3px); + opacity: 0.96; } .ml-tab.ml-tab-active { - color: var(--ml-accent, #4CAF50); - border-bottom-color: var(--ml-accent, #4CAF50); + color: var(--ml-text, #eef2f7); + background: var(--ml-bg, #222); + border-color: rgba(255,255,255,0.1); + box-shadow: + 0 -10px 24px rgba(0,0,0,0.14), + 0 10px 20px rgba(0,0,0,0.16), + inset 0 1px 0 rgba(255,255,255,0.05); + transform: translateY(1px); + opacity: 1; + z-index: 3; + } + .ml-tab.ml-tab-active::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -8px; + height: 10px; + background: var(--ml-bg, #222); + border-bottom-left-radius: 10px; + border-bottom-right-radius: 10px; } /* Loaded Models Tab */ @@ -2034,18 +2208,115 @@ class LinkerManagerDialog extends ComfyDialog { color: #d8ebff; box-shadow: 0 0 0 1px rgba(78,161,255,0.15) inset; } + .ml-info-description-wrap { + display: flex; + flex-direction: column; + gap: 8px; + } + .ml-info-description { + line-height: 1.55; + color: var(--input-text, #e0e0e0); + max-height: 220px; + overflow: hidden; + position: relative; + } + .ml-info-description:not(.is-expanded)::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 56px; + background: linear-gradient(180deg, rgba(32,32,32,0) 0%, rgba(32,32,32,0.92) 100%); + pointer-events: none; + } + .ml-info-description.is-expanded { + max-height: none; + overflow: visible; + } + .ml-info-description p, + .ml-info-description ul, + .ml-info-description ol, + .ml-info-description blockquote, + .ml-info-description pre { + margin: 0 0 10px 0; + } + .ml-info-description h1, + .ml-info-description h2, + .ml-info-description h3, + .ml-info-description h4, + .ml-info-description h5, + .ml-info-description h6 { + margin: 14px 0 8px 0; + color: var(--ml-text); + line-height: 1.3; + } + .ml-info-description ul, + .ml-info-description ol { + padding-left: 18px; + } + .ml-info-description code, + .ml-info-description pre { + background: rgba(255,255,255,0.05); + border-radius: 6px; + } + .ml-info-description code { + padding: 1px 5px; + font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; + font-size: 12px; + } + .ml-info-description pre { + padding: 10px 12px; + overflow-x: auto; + white-space: pre-wrap; + } + .ml-info-description a { + color: var(--ml-link-color, #8fc2ff); + text-decoration: none; + } + .ml-info-description a:hover { + text-decoration: underline; + } + .ml-info-description-actions { + display: flex; + justify-content: flex-end; + } + .ml-info-description-toggle { + border: 1px solid rgba(255,255,255,0.12); + background: rgba(255,255,255,0.05); + color: var(--ml-link-color, #8fc2ff); + border-radius: 999px; + padding: 4px 12px; + font-size: 11px; + cursor: pointer; + transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease; + } + .ml-info-description-toggle:hover { + background: rgba(78,161,255,0.14); + border-color: rgba(78,161,255,0.28); + color: #d6eaff; + } .ml-info-help { - display: inline-block; + display: inline-flex; + align-items: center; + justify-content: center; width: 14px; height: 14px; - background: rgba(255,255,255,0.1); + background: rgba(255,255,255,0.14); + color: var(--ml-text-muted, #b4becb); border-radius: 50%; font-size: 10px; - text-align: center; - line-height: 14px; + font-weight: 700; cursor: help; margin-left: 4px; vertical-align: middle; + border: 1px solid rgba(255,255,255,0.12); + user-select: none; + } + .ml-info-help:hover { + background: rgba(78,161,255,0.18); + color: #d9ecff; + border-color: rgba(78,161,255,0.3); } /* Images Section */ @@ -3010,6 +3281,7 @@ class LinkerManagerDialog extends ComfyDialog { switchTab(tab) { this.activeTab = tab; + this.animateTabContentTransition(); if (tab === 'missing') { this.missingTab.classList.add('ml-tab-active'); @@ -3743,38 +4015,26 @@ class LinkerManagerDialog extends ComfyDialog { createFooter() { // Store reference to download all button so we can update its text - this.downloadAllButton = $el("button.ml-btn.ml-btn-download", { - onclick: () => this.handleDownloadAllClick(), - style: { - padding: "10px 20px", - fontSize: "13px" - } + this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { + onclick: () => this.handleDownloadAllClick() }, [ $el("span.ml-btn-icon", { textContent: "☁" }), $el("span", { textContent: " Download All Missing" }) ]); // Auto-resolve button (secondary style) - this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary", { - onclick: () => this.autoResolve100Percent(), - style: { - padding: "10px 20px", - fontSize: "13px" - } + this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { + onclick: () => this.autoResolve100Percent() }, [ $el("span.ml-btn-icon", { textContent: "🔗" }), $el("span", { textContent: " Auto-Link 100%" }) ]); // Apply pending resolutions button - this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary", { + this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { id: "apply-pending-resolutions", textContent: "Apply Selected (0)", - onclick: () => this.applyPendingResolutions(), - style: { - padding: "10px 20px", - fontSize: "13px" - } + onclick: () => this.applyPendingResolutions() }); return $el("div.ml-footer", { @@ -3791,6 +4051,25 @@ class LinkerManagerDialog extends ComfyDialog { this.downloadAllButton ]); } + + animateTabContentTransition() { + if (!this.contentElement?.animate) return; + + try { + this.contentElement.animate( + [ + { opacity: 0.72, transform: 'translateY(7px)' }, + { opacity: 1, transform: 'translateY(0)' } + ], + { + duration: 180, + easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' + } + ); + } catch (error) { + console.warn('Model Linker: tab content animation failed', error); + } + } /** * Handle click on Download All / Cancel All button