diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 3603c3a..0ad715a 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -559,7 +559,7 @@ .ml-missing-list-head, #model-linker-modal .ml-missing-list-row { display: grid; - grid-template-columns: 22px minmax(170px, var(--ml-missing-model-col, 280px)) minmax(66px, var(--ml-missing-type-col, 112px)) minmax(150px, 1fr) 58px 106px; + grid-template-columns: 38px 22px minmax(170px, var(--ml-missing-model-col, 280px)) minmax(66px, var(--ml-missing-type-col, 112px)) minmax(150px, 1fr) 58px 106px; align-items: center; gap: 8px; } @@ -603,6 +603,30 @@ background: linear-gradient(90deg, rgba(114,211,138,0.12) 0%, rgba(114,211,138,0.055) 42%, rgba(114,211,138,0.018) 100%); box-shadow: inset 3px 0 0 rgba(114,211,138,0.92), inset 0 0 0 1px rgba(114,211,138,0.12); } +.ml-missing-list-row.is-batch-selected { + background: rgba(78,161,255,0.055); +} +.ml-missing-list-row.is-selected.is-batch-selected { + background: linear-gradient(90deg, rgba(114,211,138,0.13) 0%, rgba(78,161,255,0.065) 58%, rgba(78,161,255,0.025) 100%); +} +.ml-missing-row-select { + display: flex; + justify-content: center; + align-items: center; +} +.ml-missing-head-select { + display: flex; + justify-content: center; + align-items: center; +} +.ml-missing-select-all-check, +.ml-missing-row-check { + width: 14px; + height: 14px; + margin: 0; + accent-color: #72d38a; + cursor: pointer; +} .ml-missing-row-index { display: block; width: 100%; @@ -893,9 +917,14 @@ display: none; } #model-linker-modal .ml-missing-list-row { - grid-template-columns: 18px minmax(0, 1fr); + grid-template-columns: 24px 18px minmax(0, 1fr); gap: 7px; } + .ml-missing-row-select { + grid-row: 1 / span 4; + align-self: start; + margin-top: 1px; + } .ml-missing-row-index { grid-row: 1 / span 4; align-self: start; @@ -907,7 +936,7 @@ .ml-missing-row-best, .ml-missing-row-match, .ml-missing-row-sources { - grid-column: 2; + grid-column: 3; } } @@ -2344,6 +2373,83 @@ line-height: 1; backdrop-filter: none; } +.ml-footer-menu-wrap { + position: relative; + display: inline-flex; +} +.ml-footer-menu-button { + justify-content: center; +} +.ml-footer-menu-button::after { + content: "\25BE"; + margin-left: 7px; + font-size: 10px; + opacity: 0.68; +} +.ml-footer-menu { + display: none; + position: absolute; + right: 0; + bottom: calc(100% + 8px); + min-width: 212px; + max-height: min(420px, calc(100vh - 140px)); + overflow-y: auto; + padding: 5px; + border: 1px solid rgba(255,255,255,0.105); + border-radius: 8px; + background: rgba(31,31,35,0.98); + box-shadow: 0 18px 38px rgba(0,0,0,0.34); + z-index: var(--ml-z-dropdown, 10020); +} +.ml-footer-menu.is-open { + display: grid; + gap: 2px; +} +.ml-footer-menu-item { + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--ml-text); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +#model-linker-modal .ml-footer-menu .ml-footer-menu-item { + width: 100%; + min-height: 30px; + margin: 0; + padding: 0 10px; + appearance: none; + -webkit-appearance: none; + border: 0 !important; + border-radius: 6px; + background: transparent !important; + box-shadow: none !important; + color: var(--ml-text); + font: inherit; + font-size: 12px; + line-height: 1.25; + text-align: left; + justify-content: flex-start; +} +.ml-footer-menu-item:hover, +.ml-footer-menu-item:focus-visible { + outline: none; + background: rgba(255,255,255,0.07); +} +#model-linker-modal .ml-footer-menu .ml-footer-menu-item:hover, +#model-linker-modal .ml-footer-menu .ml-footer-menu-item:focus-visible { + outline: none; + background: rgba(255,255,255,0.07) !important; +} +.ml-footer-menu-divider { + height: 1px; + margin: 4px 2px; + background: rgba(255,255,255,0.08); +} .ml-btn.ml-footer-btn, .ml-btn.ml-btn-primary.ml-footer-btn, .ml-btn.ml-btn-download.ml-footer-btn { diff --git a/web/linker.js b/web/linker.js index 3c4fd14..2b986fd 100644 --- a/web/linker.js +++ b/web/linker.js @@ -33,6 +33,10 @@ class LinkerManagerDialog extends ComfyDialog { this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; this.selectedMissingModelKey = null; + this.batchSelectedMissingKeys = new Set(); + this.lastBatchSelectedMissingKey = null; + this.activeFooterMenu = null; + this.batchSearchRunning = false; this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); this.activeTabStorageKey = 'model_linker_active_tab'; this.activeTab = this.restoreActiveTab(); // Default tab @@ -124,6 +128,8 @@ class LinkerManagerDialog extends ComfyDialog { // Add click listener to hide context menu when clicking outside this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e); document.addEventListener('click', this.boundHandleContextMenuClick); + this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e); + document.addEventListener('click', this.boundHandleFooterMenuClick); window.addEventListener('resize', this._boundHandleViewportResize); } @@ -2569,6 +2575,211 @@ class LinkerManagerDialog extends ComfyDialog { this.close(); } } + + handleFooterMenuOutsideClick(e) { + if (!this.activeFooterMenu) return; + if (e.target?.closest?.('.ml-footer-menu-wrap')) return; + this.closeFooterMenus(); + } + + closeFooterMenus() { + this.activeFooterMenu = null; + this.footerMenus?.forEach?.((menu, name) => { + menu.classList.remove('is-open'); + const button = this.footerMenuButtons?.get?.(name); + button?.setAttribute('aria-expanded', 'false'); + }); + } + + setMissingFooterControlsVisible(visible) { + const display = visible ? 'inline-flex' : 'none'; + [ + this.selectMenuWrap, + this.searchMenuWrap, + this.queueExactButton, + this.applyPendingBtn, + this.autoResolveButton, + this.downloadMenuWrap + ].forEach(element => { + if (element) element.style.display = display; + }); + if (!visible) { + this.closeFooterMenus(); + } + } + + toggleFooterMenu(name) { + const menu = this.footerMenus?.get?.(name); + const button = this.footerMenuButtons?.get?.(name); + if (!menu || !button) return; + + const shouldOpen = this.activeFooterMenu !== name; + this.closeFooterMenus(); + if (shouldOpen) { + this.activeFooterMenu = name; + menu.classList.add('is-open'); + button.setAttribute('aria-expanded', 'true'); + } + } + + syncBatchSelectionForMissingModels(missingModels = this.missingModels || []) { + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + const validKeys = new Set(missingModels.map(missing => this.getMissingModelKey(missing))); + for (const key of Array.from(this.batchSelectedMissingKeys)) { + if (!validKeys.has(key)) { + this.batchSelectedMissingKeys.delete(key); + } + } + if (this.lastBatchSelectedMissingKey && !validKeys.has(this.lastBatchSelectedMissingKey)) { + this.lastBatchSelectedMissingKey = null; + } + } + + getSelectedMissingModels() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + return (this.missingModels || []).filter(missing => selectedKeys.has(this.getMissingModelKey(missing))); + } + + getMissingWithExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestLocalMatch(missing, 100)); + } + + getMissingWithoutExactLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 100)); + } + + getMissingWithPartialLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => { + const bestMatch = this.getBestLocalMatch(missing, 70); + return bestMatch && Number(bestMatch.confidence || 0) < 100; + }); + } + + getMissingWithoutLocalMatches(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestLocalMatch(missing, 70)); + } + + getMissingWithDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.getBestDownloadSourceForMissing(missing)); + } + + getMissingWithoutDownloadSources(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.getBestDownloadSourceForMissing(missing)); + } + + getSearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => this.hasRenderableSearchState(this.getSearchState(missing))); + } + + getUnsearchedMissingModels(missingModels = this.missingModels || []) { + return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing))); + } + + selectBatchMissingModels(mode) { + const missingModels = this.missingModels || []; + if (!missingModels.length) { + this.showNotification('No missing models to select.', 'info'); + return; + } + + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + + if (mode === 'none') { + this.batchSelectedMissingKeys.clear(); + } else if (mode === 'invert') { + const nextSelection = new Set(); + for (const missing of missingModels) { + const key = this.getMissingModelKey(missing); + if (!this.batchSelectedMissingKeys.has(key)) { + nextSelection.add(key); + } + } + this.batchSelectedMissingKeys = nextSelection; + } else { + let models = missingModels; + if (mode === 'exact') { + models = this.getMissingWithExactLocalMatches(missingModels); + } else if (mode === 'no_exact') { + models = this.getMissingWithoutExactLocalMatches(missingModels); + } else if (mode === 'partial') { + models = this.getMissingWithPartialLocalMatches(missingModels); + } else if (mode === 'no_local') { + models = this.getMissingWithoutLocalMatches(missingModels); + } else if (mode === 'downloadable') { + models = this.getMissingWithDownloadSources(missingModels); + } else if (mode === 'no_download') { + models = this.getMissingWithoutDownloadSources(missingModels); + } else if (mode === 'searched') { + models = this.getSearchedMissingModels(missingModels); + } else if (mode === 'unsearched') { + models = this.getUnsearchedMissingModels(missingModels); + } + this.batchSelectedMissingKeys = new Set(models.map(missing => this.getMissingModelKey(missing))); + } + + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + this.closeFooterMenus(); + } + + refreshBatchSelectionUi() { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + this.contentElement?.querySelectorAll?.('.ml-missing-list-row')?.forEach(row => { + const key = row.getAttribute('data-missing-key'); + const selected = selectedKeys.has(key); + row.classList.toggle('is-batch-selected', selected); + const checkbox = row.querySelector('.ml-missing-row-check'); + if (checkbox) { + checkbox.checked = selected; + } + }); + this.updateBatchSelectAllCheckbox(); + } + + updateBatchSelectAllCheckbox() { + const checkbox = this.contentElement?.querySelector?.('.ml-missing-select-all-check'); + if (!checkbox) return; + + const totalCount = (this.missingModels || []).length; + const selectedCount = this.getSelectedMissingModels().length; + checkbox.checked = totalCount > 0 && selectedCount === totalCount; + checkbox.indeterminate = selectedCount > 0 && selectedCount < totalCount; + checkbox.disabled = totalCount === 0; + } + + setBatchSelectionForKey(key, selected) { + if (!key) return; + if (!(this.batchSelectedMissingKeys instanceof Set)) { + this.batchSelectedMissingKeys = new Set(); + } + if (selected) { + this.batchSelectedMissingKeys.add(key); + } else { + this.batchSelectedMissingKeys.delete(key); + } + } + + applyBatchSelectionRange(sortedMissingModels, fromKey, toKey, selected) { + const models = sortedMissingModels || this.missingModels || []; + const fromIndex = models.findIndex(missing => this.getMissingModelKey(missing) === fromKey); + const toIndex = models.findIndex(missing => this.getMissingModelKey(missing) === toKey); + + if (fromIndex < 0 || toIndex < 0) { + this.setBatchSelectionForKey(toKey, selected); + return; + } + + const start = Math.min(fromIndex, toIndex); + const end = Math.max(fromIndex, toIndex); + for (let index = start; index <= end; index += 1) { + this.setBatchSelectionForKey(this.getMissingModelKey(models[index]), selected); + } + } createHeader() { // Create tabs @@ -4242,25 +4453,19 @@ class LinkerManagerDialog extends ComfyDialog { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } - this.downloadAllButton.style.display = 'inline-flex'; - this.autoResolveButton.style.display = 'inline-flex'; - this.applyPendingBtn.style.display = 'inline-flex'; + this.setMissingFooterControlsVisible(true); this.loadWorkflowData(); } else if (this.activeTab === 'loaded') { if (this.contentElement) { this.contentElement.style.overflowY = 'auto'; } - this.downloadAllButton.style.display = 'none'; - this.autoResolveButton.style.display = 'none'; - this.applyPendingBtn.style.display = 'none'; + this.setMissingFooterControlsVisible(false); this.loadLoadedModels(); } else { if (this.contentElement) { this.contentElement.style.overflowY = 'hidden'; } - this.downloadAllButton.style.display = 'none'; - this.autoResolveButton.style.display = 'none'; - this.applyPendingBtn.style.display = 'none'; + this.setMissingFooterControlsVisible(false); this.displayOptions(); } } @@ -5038,9 +5243,118 @@ class LinkerManagerDialog extends ComfyDialog { this.setTooltip(this.applyPendingBtn, tooltip); this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty); + this.updateBatchFooterButtons(); + } + + updateBatchFooterButtons() { + const missingModels = this.missingModels || []; + const selectedCount = this.getSelectedMissingModels().length; + const totalCount = missingModels.length; + const pendingCount = this.pendingResolutions?.length || 0; + const activeCount = Object.keys(this.activeDownloads || {}).length; + const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length; + const downloadableAll = this.getMissingWithDownloadSources(missingModels).length; + + if (this.selectMenuButton) { + this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`; + this.selectMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0); + } + if (this.searchMenuButton) { + this.searchMenuButton.textContent = this.batchSearchRunning ? 'Searching...' : 'Search'; + this.searchMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0 || this.batchSearchRunning); + } + if (this.downloadMenuButton) { + const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download'; + this.downloadMenuButton.textContent = label; + this.downloadMenuButton.classList.toggle('ml-btn-danger', activeCount > 0); + this.downloadMenuButton.classList.toggle('ml-btn-download', activeCount === 0); + this.downloadMenuButton.classList.toggle('ml-btn-is-disabled', activeCount === 0 && downloadableAll === 0); + this.setTooltip( + this.downloadMenuButton, + activeCount > 0 + ? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` + : `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.` + ); + } + if (this.applyPendingBtn) { + this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`; + } + } + + createFooterMenu(name, label, items = [], buttonClass = 'ml-btn-secondary') { + const button = $el(`button.ml-btn.${buttonClass}.ml-footer-btn.ml-footer-menu-button`, { + type: 'button', + 'aria-haspopup': 'menu', + 'aria-expanded': 'false', + onclick: (event) => { + event.stopPropagation(); + if (button.classList.contains('ml-btn-is-disabled')) { + if (name === 'search' && this.batchSearchRunning) { + this.showNotification('Batch search is already running.', 'info'); + } else if (name === 'download') { + this.showNotification('No missing models have downloadable sources yet.', 'info'); + } else { + this.showNotification('No missing models available.', 'info'); + } + return; + } + this.toggleFooterMenu(name); + } + }, [$el("span", { textContent: label })]); + + const menu = $el("div.ml-footer-menu", { role: 'menu' }, items.map(item => ( + item === 'divider' + ? $el("div.ml-footer-menu-divider", {}) + : $el("button.ml-footer-menu-item", { + type: 'button', + role: 'menuitem', + onclick: (event) => { + event.stopPropagation(); + item.action(); + } + }, [$el("span", { textContent: item.label })]) + ))); + + this.footerMenuButtons.set(name, button); + this.footerMenus.set(name, menu); + + return $el("div.ml-footer-menu-wrap", {}, [button, menu]); } createFooter() { + this.footerMenus = new Map(); + this.footerMenuButtons = new Map(); + + const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ + { label: 'Select All', action: () => this.selectBatchMissingModels('all') }, + { label: 'Select None', action: () => this.selectBatchMissingModels('none') }, + { label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') }, + 'divider', + { label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') }, + { label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') }, + { label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') }, + { label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') }, + 'divider', + { label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') }, + { label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') }, + 'divider', + { label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') }, + { label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') } + ]); + this.selectMenuButton = this.footerMenuButtons.get('select'); + this.selectMenuWrap = selectMenu; + + const searchMenu = this.createFooterMenu('search', 'Search', [ + { label: 'Search Selected', action: () => this.searchMissingBatch('selected', 'all') }, + { label: 'Search All Missing', action: () => this.searchMissingBatch('all', 'all') }, + { label: 'Search Unsearched', action: () => this.searchMissingBatch('unsearched', 'all') }, + 'divider', + { label: 'Selected: CivitAI', action: () => this.searchMissingBatch('selected', 'civitai') }, + { label: 'Selected: HuggingFace', action: () => this.searchMissingBatch('selected', 'huggingface') } + ]); + this.searchMenuButton = this.footerMenuButtons.get('search'); + this.searchMenuWrap = searchMenu; + // Store reference to download all button so we can update its text this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", { "data-tooltip": "Download every missing model that has a known download source.", @@ -5060,6 +5374,12 @@ class LinkerManagerDialog extends ComfyDialog { $el("span.ml-btn-icon", { textContent: "🔗" }), $el("span", { textContent: " Auto-Link 100%" }) ]); + this.queueExactButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", { + type: 'button', + onclick: () => this.queueExactLocalMatchesBatch('selected') + }, [ + $el("span", { textContent: "Queue Exact" }) + ]); // Apply pending resolutions button this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", { @@ -5076,17 +5396,34 @@ class LinkerManagerDialog extends ComfyDialog { this.applyPendingBtn.classList.add('ml-btn-is-disabled'); this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches'); + this.queueExactButton.setAttribute('aria-label', 'Queue exact local matches'); this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links'); this.applyPendingBtn.setAttribute('aria-disabled', 'true'); this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.'); this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); + this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.'); this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.'); + + const downloadMenu = this.createFooterMenu('download', 'Download', [ + { label: 'Download Selected', action: () => this.downloadMissingBatch('selected') }, + { label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') }, + 'divider', + { label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } + ], 'ml-btn-download'); + this.downloadMenuButton = this.footerMenuButtons.get('download'); + this.downloadMenuWrap = downloadMenu; + this.downloadAllButton = this.downloadMenuButton; + this.downloadAllButton.setAttribute('aria-label', 'Download missing models'); + this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.'); return $el("div.ml-footer", {}, [ - this.autoResolveButton, + selectMenu, + searchMenu, + this.queueExactButton, this.applyPendingBtn, - this.downloadAllButton + this.autoResolveButton, + downloadMenu ]); } @@ -5147,6 +5484,10 @@ class LinkerManagerDialog extends ComfyDialog { */ updateDownloadAllButtonState() { if (!this.downloadAllButton) return; + if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) { + this.updateBatchFooterButtons(); + return; + } const activeCount = Object.keys(this.activeDownloads).length; if (activeCount > 0) { @@ -5837,6 +6178,9 @@ class LinkerManagerDialog extends ComfyDialog {