diff --git a/web/css/linker-main.css b/web/css/linker-main.css index ee5fb54..2447eed 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -75,24 +75,31 @@ } .ml-loaded-filters { - padding: var(--ml-spacing-12) var(--ml-spacing-20) var(--ml-spacing-6); + padding: 0; } .ml-loaded-filter-row { display: flex; gap: var(--ml-spacing-8); flex-wrap: wrap; + justify-content: flex-end; margin-bottom: 0; } .ml-btn-filter, -#model-linker-modal .ml-btn-filter { +.ml-copy-mode, +.ml-btn-copy-compact, +.ml-copy-main-btn, +#model-linker-modal .ml-btn-filter, +#model-linker-modal .ml-copy-mode, +#model-linker-modal .ml-btn-copy-compact, +#model-linker-modal .ml-copy-main-btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--ml-spacing-5); - min-height: var(--ml-missing-search-button-height, 34px); - height: var(--ml-missing-search-button-height, 34px); + min-height: 30px; + height: 30px; padding: 0 12px; color: var(--ml-text); background: var(--ml-missing-control-bg, rgba(255,255,255,0.035)); @@ -110,7 +117,13 @@ } .ml-btn-filter:hover:not(:disabled), -#model-linker-modal .ml-btn-filter:hover:not(:disabled) { +.ml-copy-mode:hover:not(:disabled), +.ml-btn-copy-compact:hover:not(:disabled), +.ml-copy-main-btn:hover:not(:disabled), +#model-linker-modal .ml-btn-filter:hover:not(:disabled), +#model-linker-modal .ml-copy-mode:hover:not(:disabled), +#model-linker-modal .ml-btn-copy-compact:hover:not(:disabled), +#model-linker-modal .ml-copy-main-btn:hover:not(:disabled) { color: var(--ml-text); background: var(--ml-missing-control-bg-hover, rgba(255,255,255,0.06)); border-color: var(--ml-missing-control-border-hover, rgba(255,255,255,0.17)); @@ -118,21 +131,31 @@ } .ml-btn-filter:focus-visible, -#model-linker-modal .ml-btn-filter:focus-visible { +.ml-copy-mode:focus-visible, +.ml-btn-copy-compact:focus-visible, +.ml-copy-main-btn:focus-visible, +#model-linker-modal .ml-btn-filter:focus-visible, +#model-linker-modal .ml-copy-mode:focus-visible, +#model-linker-modal .ml-btn-copy-compact:focus-visible, +#model-linker-modal .ml-copy-main-btn:focus-visible { outline: none; border-color: var(--ml-missing-control-focus-border, rgba(114,211,138,0.30)); box-shadow: 0 0 0 2px var(--ml-missing-control-focus-ring, rgba(114,211,138,0.08)); } .ml-btn-filter.active, -#model-linker-modal .ml-btn-filter.active { +.ml-copy-mode.active, +#model-linker-modal .ml-btn-filter.active, +#model-linker-modal .ml-copy-mode.active { color: #dffbe5; background: rgba(114,211,138,0.13); border-color: rgba(114,211,138,0.32); } .ml-btn-filter.active:hover:not(:disabled), -#model-linker-modal .ml-btn-filter.active:hover:not(:disabled) { +.ml-copy-mode.active:hover:not(:disabled), +#model-linker-modal .ml-btn-filter.active:hover:not(:disabled), +#model-linker-modal .ml-copy-mode.active:hover:not(:disabled) { background: rgba(114,211,138,0.17); border-color: rgba(114,211,138,0.42); } @@ -143,23 +166,74 @@ .ml-model-section { margin-bottom: var(--ml-spacing-8); - padding: var(--ml-spacing-8); + padding: var(--ml-spacing-10); background: var(--ml-card-bg-alt, #252525); + border: 1px solid rgba(255, 255, 255, 0.055); border-radius: var(--ml-ui-radius-sm); } .ml-model-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ml-spacing-10); margin-bottom: var(--ml-spacing-6); padding-bottom: var(--ml-spacing-5); border-bottom: 1px solid var(--ml-color-gray-900); } +.ml-model-section-heading { + display: flex; + align-items: baseline; + gap: var(--ml-spacing-8); + min-width: 0; +} + .ml-model-section-title { color: var(--ml-text); font-size: var(--ml-font-lg); font-weight: 600; } +.ml-model-section-total { + color: var(--ml-text-dim); + font-size: var(--ml-font-xs); + font-weight: 600; + white-space: nowrap; +} + +.ml-model-section-counts { + display: flex; + align-items: center; + gap: var(--ml-spacing-5); + flex-wrap: wrap; + justify-content: flex-end; +} + +.ml-model-count-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 var(--ml-spacing-7); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.09); + color: var(--ml-text-muted); + background: rgba(255, 255, 255, 0.035); + font-size: var(--ml-font-xs); + font-weight: 700; + white-space: nowrap; +} + +.ml-model-count-pill.is-active { + color: #c9f6d0; + border-color: rgba(114, 211, 138, 0.25); + background: rgba(114, 211, 138, 0.09); +} + +.ml-model-count-pill.is-inactive { + color: var(--ml-text-dim); +} + .ml-model-group { margin-bottom: var(--ml-spacing-6); } @@ -168,14 +242,31 @@ display: flex; justify-content: space-between; align-items: center; + gap: var(--ml-spacing-8); margin-bottom: var(--ml-spacing-5); } .ml-model-group-label { + display: inline-flex; + align-items: center; + gap: var(--ml-spacing-5); font-size: var(--ml-font-sm); font-weight: 600; } +.ml-model-group-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; +} + +.ml-model-group-count { + color: var(--ml-text-dim); + font-size: var(--ml-font-xs); + font-weight: 700; +} + .ml-model-group-label-active { color: #4caf50; } @@ -185,12 +276,16 @@ } .ml-btn-copy-compact { - padding: var(--ml-spacing-3) var(--ml-spacing-8); - font-size: var(--ml-font-xs); + font-size: 12px; +} + +.ml-copy-btn-icon { + width: 13px; + height: 13px; } .ml-btn-copy-compact.is-muted { - opacity: 0.6; + opacity: 1; } .ml-model-chip-list { @@ -204,32 +299,85 @@ } .ml-copy-section { - padding: var(--ml-spacing-16) var(--ml-spacing-20); + position: sticky; + bottom: 0; + z-index: 2; + padding: var(--ml-spacing-10) var(--ml-spacing-12) var(--ml-spacing-12); + background: var(--ml-bg); border-top: 1px solid var(--ml-border); } .ml-copy-label { + margin: 0; font-size: var(--ml-font-md); + font-weight: 700; + color: var(--ml-text); +} + +.ml-copy-meta { + margin-top: var(--ml-spacing-2); + font-size: var(--ml-font-xs); color: var(--ml-text-muted); +} + +.ml-copy-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ml-spacing-10); margin-bottom: var(--ml-spacing-8); } -.ml-copy-row { +.ml-copy-heading { + min-width: 0; +} + +.ml-copy-actions { display: flex; - gap: var(--ml-spacing-8); align-items: center; + gap: var(--ml-spacing-6); + flex-wrap: wrap; + justify-content: flex-end; +} + +.ml-copy-mode { + font-size: 12px; +} + +.ml-copy-main-btn { + font-size: 12px; } .ml-copy-code { - flex: 1; + display: block; + width: 100%; + max-height: 92px; + box-sizing: border-box; padding: var(--ml-spacing-8) var(--ml-spacing-12); background: var(--ml-card-bg); border-radius: var(--ml-radius-md); font-family: "SF Mono", "Consolas", monospace; font-size: var(--ml-font-sm); + line-height: 1.45; color: #90caf9; - overflow-x: auto; - white-space: nowrap; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +@media (max-width: 720px) { + .ml-loaded-models-header, + .ml-copy-toolbar, + .ml-model-section-header { + align-items: stretch; + flex-direction: column; + } + + .ml-loaded-filter-row, + .ml-copy-actions, + .ml-model-section-counts { + justify-content: flex-start; + } } .ml-error-text { @@ -2388,21 +2536,42 @@ padding: 0; } .ml-loaded-models-header { - padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ml-spacing-12); + padding: 10px 12px; border-bottom: 1px solid var(--ml-border); } +.ml-loaded-title-block { + min-width: 0; +} .ml-loaded-models-title { font-size: 15px; font-weight: 600; color: var(--ml-text); - margin: 0 0 8px 0; + margin: 0; +} +.ml-loaded-total { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 20px; + margin-left: var(--ml-spacing-6); + padding: 0 var(--ml-spacing-7); + border-radius: 999px; + background: rgba(114, 211, 138, 0.12); + color: #dffbe5; + font-size: var(--ml-font-xs); } .ml-loaded-models-subtitle { + margin: var(--ml-spacing-4) 0 0; font-size: 12px; color: var(--ml-text-muted); } .ml-models-list { - padding: 4px 12px 8px; + padding: 8px 12px 10px; display: flex; flex-direction: column; gap: 6px; @@ -2410,7 +2579,7 @@ .ml-model-chip { display: inline-flex; align-items: center; - padding: 6px 12px; + padding: 5px 10px; background: #3a3a3a; border-radius: 6px; font-family: 'SF Mono', 'Consolas', 'Monaco', monospace; @@ -2435,6 +2604,13 @@ margin-left: 8px; } +@media (max-width: 720px) { + .ml-loaded-models-header { + align-items: stretch; + flex-direction: column; + } +} + .model-linker-selected { margin: 8px 0; padding: 10px 12px; diff --git a/web/linker.js b/web/linker.js index 0486d39..c81b0ef 100644 --- a/web/linker.js +++ b/web/linker.js @@ -1581,6 +1581,10 @@ class LinkerManagerDialog extends ComfyDialog { .replace(/'/g, '''); } + escapeJsString(value) { + return this.escapeHtml(JSON.stringify(String(value ?? ''))); + } + truncateText(value, maxLength = 160) { const text = String(value ?? '').trim(); if (!text) return ''; @@ -3936,14 +3940,26 @@ class LinkerManagerDialog extends ComfyDialog { const inactiveString = buildCategoryStrings('inactive'); const allString = buildCategoryStrings('all'); - const self = this; + const copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon'); + const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); + const modelToken = (model, category) => { + const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown'; + const name = stripModelExtension(fullName); + const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00'; + return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; + }; + const copyButtonHtml = (text, extraClass = '') => ` + `; + const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`; let html = `
-

${total} Model${total > 1 ? 's' : ''} in Workflow

-

LoraManager / LoraLoaderV2 nodes distinguish active/inactive

-
-
+
+

Loaded Models ${total}

+

${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}

+
@@ -3957,26 +3973,28 @@ class LinkerManagerDialog extends ComfyDialog { const displayName = this.getCategoryDisplayName(category); const hasActive = modelsObj.active.length > 0; const hasInactive = modelsObj.inactive.length > 0; + const sectionTotal = modelsObj.active.length + modelsObj.inactive.length; html += `
`; - // Add category header html += `
- ${displayName.toUpperCase()} +
+ ${this.escapeHtml(displayName.toUpperCase())} + ${countText(sectionTotal, 'model')} +
+
+ ${hasActive ? `${countText(modelsObj.active.length, 'active')}` : ''} + ${hasInactive ? `${countText(modelsObj.inactive.length, 'inactive')}` : ''} +
`; if (hasActive) { - const activeStr = modelsObj.active.map(m => { - const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; - return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; - }).join(' '); + const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' '); - html += `
+ html += `
- ● ACTIVE - + Active ${modelsObj.active.length} + ${copyButtonHtml(activeStr)}
`; @@ -3985,23 +4003,18 @@ class LinkerManagerDialog extends ComfyDialog { const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; const modelData = encodeURIComponent(JSON.stringify(model)); - html += `${name}${strength !== null ? `${strength}` : ''}`; + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; } if (hasInactive) { - const inactiveStr = modelsObj.inactive.map(m => { - const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown'; - let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); - const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00'; - return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`; - }).join(' '); + const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' '); - html += `
+ html += `
- ○ INACTIVE - + Inactive ${modelsObj.inactive.length} + ${copyButtonHtml(inactiveStr, 'is-muted')}
`; @@ -4010,7 +4023,7 @@ class LinkerManagerDialog extends ComfyDialog { const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, ''); const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null; const modelData = encodeURIComponent(JSON.stringify(model)); - html += `${name}${strength !== null ? `${strength}` : ''}`; + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; } @@ -4021,12 +4034,20 @@ class LinkerManagerDialog extends ComfyDialog { const copySectionId = 'ml-copy-' + Date.now(); html += `
-
-
Copy all:
-
- ${this.escapeHtml(allString)} - +
+
+
+
Copy all
+
${countText(activeCount + inactiveCount, 'token')}
+
+
+ + + + +
+ ${this.escapeHtml(allString)}
`; @@ -8036,6 +8057,44 @@ window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) { } }; +window.MLSetCopyMode = function(sectionId, mode) { + const section = document.getElementById(sectionId); + if (!section) return; + + const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all'; + const codeEl = section.querySelector('.ml-copy-code'); + const labelEl = section.querySelector('.ml-copy-label'); + const metaEl = section.querySelector('.ml-copy-meta'); + const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive'; + const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount'; + const labelMap = { + all: 'Copy all', + active: 'Copy active', + inactive: 'Copy inactive' + }; + + if (codeEl) codeEl.textContent = section.dataset[dataKey] || ''; + if (labelEl) labelEl.textContent = labelMap[normalizedMode]; + if (metaEl) { + const count = Number(section.dataset[countKey] || 0); + metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`; + } + + section.querySelectorAll('.ml-copy-mode').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode); + }); +}; + +window.MLSetCopyButtonFeedback = function(btn, text) { + if (!btn) return; + const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML; + btn.dataset.mlOriginalHtml = originalHtml; + btn.textContent = text; + setTimeout(() => { + if (btn.isConnected) btn.innerHTML = originalHtml; + }, 1500); +}; + window.MLFilterSwitch = function(filter) { const filterBtn = document.getElementById('filter-' + filter); if (!filterBtn) return; @@ -8049,10 +8108,8 @@ window.MLFilterSwitch = function(filter) { const hasActive = s.getAttribute('data-ml-active') === 'true'; const hasInactive = s.getAttribute('data-ml-inactive') === 'true'; - // Get child divs: category header, active section, inactive section - const childDivs = Array.from(s.children).filter(c => c.tagName === 'DIV'); - const activeSection = childDivs[1]; - const inactiveSection = childDivs[2]; + const activeSection = s.querySelector('.ml-model-group-active'); + const inactiveSection = s.querySelector('.ml-model-group-inactive'); if (filter === 'all') { s.style.display = 'block'; @@ -8071,27 +8128,13 @@ window.MLFilterSwitch = function(filter) { const copySection = document.querySelector('[id^="ml-copy-"]'); if (copySection) { - const codeEl = copySection.querySelector('code'); - const labelEl = copySection.querySelector('div'); - - if (filter === 'all') { - codeEl.textContent = copySection.dataset.mlAll; - labelEl.textContent = 'Copy all:'; - } else if (filter === 'active') { - codeEl.textContent = copySection.dataset.mlActive; - labelEl.textContent = 'Copy active:'; - } else if (filter === 'inactive') { - codeEl.textContent = copySection.dataset.mlInactive; - labelEl.textContent = 'Copy inactive:'; - } + window.MLSetCopyMode(copySection.id, filter); } }; window.MLCopy = function(text, btn) { navigator.clipboard.writeText(text).then(() => { - const orig = btn.textContent; - btn.textContent = '✓'; - setTimeout(() => btn.textContent = orig, 1500); + window.MLSetCopyButtonFeedback(btn, 'Copied'); }); }; @@ -8099,9 +8142,7 @@ window.MLCopyCode = function(sectionId, btn) { const section = document.getElementById(sectionId); const codeEl = section.querySelector('code'); navigator.clipboard.writeText(codeEl.textContent).then(() => { - const orig = btn.textContent; - btn.textContent = '✓ Copied!'; - setTimeout(() => btn.textContent = orig, 1500); + window.MLSetCopyButtonFeedback(btn, 'Copied'); }); }; diff --git a/web/utils/icon_utils.js b/web/utils/icon_utils.js index e64a05e..2c02e06 100644 --- a/web/utils/icon_utils.js +++ b/web/utils/icon_utils.js @@ -6,6 +6,7 @@ const SVG_ICONS = { refreshCw: ``, locate: ``, download: ``, + copy: ``, externalLink: ``, internalLink: ``, database: ``,