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 queueMethods = { createContent() { // Wrap the body in a two-column layout: left = items, right = queued panel const body = $el("div", { id: "model-resolver-body" }); this.contentElement = $el("div.mr-scrollable", { id: "model-resolver-content" }); this.queueElement = $el("div", { id: "model-resolver-queue" }, [ this.createQueuePanel() ]); // Splitter between content and queue this.splitterElement = $el("div", { id: "model-resolver-splitter", ondragstart: (e) => e.preventDefault() }); body.appendChild(this.contentElement); body.appendChild(this.splitterElement); body.appendChild(this.queueElement); // Restore saved queue width and wire splitter try { const savedSplit = localStorage.getItem('model_resolver_split_w'); if (savedSplit) { const w = parseInt(savedSplit, 10); if (!isNaN(w) && w > 0) { this.queueElement.style.width = `${w}px`; } } } catch (e) { } try { const onSplitMouseDown = (e) => this.startSplitDrag(e); this.splitterElement.addEventListener('mousedown', onSplitMouseDown); this._splitterMouseDown = onSplitMouseDown; } catch (e) { } // Toggle icon always visible try { this.queueToggleIcon = $el("button", { id: "queue-toggle-icon", type: "button", "aria-label": "Toggle queued selections", onclick: (event) => this.onQueueEdgeClick(event), onmousedown: (event) => this.startQueueEdgeDrag(event) }); body.appendChild(this.queueToggleIcon); this.updateQueueToggleIcon(); } catch (e) { } // Restore queue collapsed state try { const col = localStorage.getItem('model_resolver_queue_collapsed'); if (col === '1') this.setQueueCollapsed(true); } catch (e) { } return body; }, createQueuePanel() { this.queueTitle = $el("div#queue-title.mr-queue-title", { textContent: "Queued Selections (0)" }); this.queueToggleButton = $el("button", { id: "queue-toggle", className: "mr-btn mr-btn-secondary mr-btn-sm", textContent: "Collapse", onclick: () => this.toggleQueueCollapsed() }); this.queueClearButton = $el("button", { id: "queue-clear", className: "mr-btn mr-btn-secondary mr-btn-sm", textContent: "Clear All", onclick: () => this.clearAllQueued() }); // Header row with title and clear button this.queueHeader = $el("div.mr-queue-header", {}, [ this.queueTitle, $el("div.mr-queue-actions", {}, [ this.queueToggleButton, this.queueClearButton ]) ]); // Scrollable list this.queueList = $el("div#queue-list.mr-queue-list"); const panel = $el("div.mr-queue-panel", {}, [ $el("div.mr-queue-stack", {}, [this.queueHeader, this.queueList]) ]); return panel; }, updateQueuePanel() { if (!this.queueList || !this.queueHeader) return; const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; // Update title count const title = this.queueTitle || this.queueHeader.querySelector('#queue-title'); if (title) title.textContent = `Queued Selections (${list.length})`; const toggleBtn = this.queueToggleButton || this.queueHeader.querySelector('#queue-toggle'); if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse'; if (!list.length) { this.queueList.innerHTML = '
No selections queued.
'; return; } let html = '
'; for (let i = 0; i < list.length; i++) { const r = list[i]; const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString(); const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node'); const orig = (r.original_path || '').toString(); const rmId = `queue-remove-${i}`; html += `
`; html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`; html += `
Original${this.escapeHtml(orig)}
`; html += `
Selected${this.escapeHtml(label)}
`; html += `
`; html += `
`; } html += '
'; this.queueList.innerHTML = html; // Wire remove buttons for (let i = 0; i < list.length; i++) { const rmId = `queue-remove-${i}`; const btn = this.queueList.querySelector(`#${rmId}`); if (btn) { btn.addEventListener('click', () => this.removeQueuedByIndex(i)); } } }, // Remove queued by index removeQueuedByIndex(i) { const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; if (i < 0 || i >= list.length) return; const r = list[i]; // Remove this.pendingResolutions.splice(i, 1); this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); // Update per-item selected bar const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; this.updateSelectedBarForMissing?.(m); this.updateApplyPendingButton?.(); this.updateQueuePanel(); }, // Clear all queued selections clearAllQueued() { this.pendingResolutions = []; this.pendingIndex = new Map(); this.savePendingQueueForActiveWorkflow(); this.updateApplyPendingButton?.(); this.updateQueuePanel(); try { document.querySelectorAll('.model-resolver-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; }); } catch (e) { /* ignore */ } }, // Update selected bar for a specific missing model slot updateSelectedBarForMissing(missing) { if (!missing) return; const nodeId = missing.node_id; const widgetIndex = missing.widget_index; const subgraphId = missing.subgraph_id || ''; const isTopLevel = missing.is_top_level !== false; const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`; const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`); if (!selectedBar) return; // Find selection for this slot let selection = null; let selectionIdx = -1; if (this.pendingIndex.has(key)) { const idx = this.pendingIndex.get(key); if (idx >= 0 && idx < this.pendingResolutions.length) { selection = this.pendingResolutions[idx]; selectionIdx = idx; } } if (!selection) { selectedBar.style.display = 'none'; selectedBar.innerHTML = ''; return; } // Build selected bar content const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || ''; const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`; selectedBar.innerHTML = `
`; selectedBar.innerHTML += `✓ Selected:`; selectedBar.innerHTML += `${label}`; selectedBar.innerHTML += ``; selectedBar.innerHTML += `
`; selectedBar.style.display = 'block'; // Wire remove button - use key-based removal const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`); if (removeBtn) { removeBtn.addEventListener('click', () => { this.removeQueuedByKey(key); }); } }, // Remove queued by key (more reliable than index) removeQueuedByKey(key) { if (!key || !this.pendingIndex.has(key)) return; // Find and remove the item with this key const idx = this.pendingIndex.get(key); if (idx >= 0 && idx < this.pendingResolutions.length) { const r = this.pendingResolutions[idx]; // Update the selected bar before removing const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level }; // Remove from array this.pendingResolutions.splice(idx, 1); this.rebuildPendingIndex(); this.savePendingQueueForActiveWorkflow(); this.updateSelectedBarForMissing(m); this.updateApplyPendingButton?.(); this.updateQueuePanel(); } }, // Rebuild pending index after modification rebuildPendingIndex() { this.pendingIndex = new Map(); for (let i = 0; i < this.pendingResolutions.length; i++) { const r = this.pendingResolutions[i]; const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`; this.pendingIndex.set(key, i); } }, // Collapse/expand queue panel toggleQueueCollapsed() { this.setQueueCollapsed(!this.queueCollapsed); }, setQueueCollapsed(collapsed) { this.queueCollapsed = !!collapsed; if (this.queueCollapsed) { try { localStorage.setItem('model_resolver_queue_collapsed', '1'); } catch (e) { } } else { try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { } } this.updateQueueVisibility(); this.updateQueuePanel(); }, updateQueueVisibility() { const isMissingTab = this.activeTab === 'missing'; const showQueuePanel = isMissingTab && !this.queueCollapsed; if (this.queueElement) { this.queueElement.style.display = showQueuePanel ? '' : 'none'; } if (this.splitterElement) { this.splitterElement.style.display = showQueuePanel ? '' : 'none'; } if (this.queueToggleIcon) { this.queueToggleIcon.style.display = isMissingTab ? '' : 'none'; } this.updateQueueToggleIcon(); }, updateQueueToggleIcon() { if (!this.queueToggleIcon) return; this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none'; this.queueToggleIcon.textContent = ''; this.queueToggleIcon.removeAttribute('data-tooltip'); this.queueToggleIcon.removeAttribute('title'); if (this.queueCollapsed) { this.queueToggleIcon.classList.add('is-collapsed'); this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections'); } else { this.queueToggleIcon.classList.remove('is-collapsed'); this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections'); } }, onQueueEdgeClick(event) { if (this._suppressQueueEdgeClick) { event?.preventDefault?.(); event?.stopPropagation?.(); this._suppressQueueEdgeClick = false; return; } this.toggleQueueCollapsed(); }, startQueueEdgeDrag(event) { if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return; const startX = event.clientX; const previousCursor = document.body.style.cursor; event.preventDefault(); document.body.style.cursor = 'col-resize'; this._suppressQueueEdgeClick = true; this.splitterElement.style.display = ''; document.getElementById('model-resolver-body')?.classList.add('mr-queue-preview-collapsed'); this.queueElement.style.display = 'none'; this.queueElement.style.width = '0px'; this.startSplitDrag( { clientX: startX, preventDefault: () => {} }, { startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true } ); }, // Begin split drag for resizable panels startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) { try { e?.preventDefault?.(); if (!this.queueElement) return; const rect = this.queueElement.getBoundingClientRect(); const body = document.getElementById('model-resolver-body'); const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth }; this._splitDragging = true; body?.classList.add('mr-is-resizing-queue'); this._splitStart = { x: e.clientX, startWidth: Number.isFinite(startWidth) ? startWidth : rect.width, containerWidth: bodyRect.width }; this._lastSplitDragApply = 0; this._splitPreviewCollapsed = !!startCollapsedPreview; this._splitEdgeOpen = !!edgeOpen; this._splitEdgeOpenedPastThreshold = false; this._splitPreviousCursor = previousCursor; this._prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = 'none'; this._onSplitMove = (ev) => this.onSplitDrag(ev); this._onSplitUp = () => this.endSplitDrag(); document.addEventListener('mousemove', this._onSplitMove); document.addEventListener('mouseup', this._onSplitUp, { once: true }); } catch (err) { /* ignore */ } }, onSplitDrag(e) { if (!this._splitDragging || !this._splitStart || !this.queueElement) return; const dx = e.clientX - this._splitStart.x; let newW = this._splitStart.startWidth - dx; const minW = this._splitEdgeOpen ? 0 : 56; const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360)); if (newW < minW) newW = minW; if (newW > maxW) newW = maxW; this._pendingSplitWidth = Math.round(newW); if (this._splitEdgeOpen && this._pendingSplitWidth > 140) { this._splitEdgeOpenedPastThreshold = true; if (this.queueCollapsed) { this.queueCollapsed = false; try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (error) { } this.updateQueueToggleIcon(); this.updateQueuePanel(); } } const previewCollapsed = this._splitEdgeOpen ? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140) : this._pendingSplitWidth <= 140; if (previewCollapsed !== this._splitPreviewCollapsed) { this._splitPreviewCollapsed = previewCollapsed; const body = document.getElementById('model-resolver-body'); body?.classList.toggle('mr-queue-preview-collapsed', previewCollapsed); this.queueElement.style.display = previewCollapsed ? 'none' : ''; } const now = performance.now(); if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return; this._lastSplitDragApply = now; if (this._splitDragFrame) return; this._splitDragFrame = requestAnimationFrame(() => { this._splitDragFrame = null; if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return; this.queueElement.style.width = `${this._pendingSplitWidth}px`; }); }, endSplitDrag() { if (!this._splitDragging) return; this._splitDragging = false; if (this._splitDragFrame) { cancelAnimationFrame(this._splitDragFrame); this._splitDragFrame = null; } if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) { this.queueElement.style.width = `${this._pendingSplitWidth}px`; } const body = document.getElementById('model-resolver-body'); body?.classList.remove('mr-is-resizing-queue'); body?.classList.remove('mr-queue-preview-collapsed'); document.removeEventListener('mousemove', this._onSplitMove); if (this._splitEdgeOpen && !this._pendingSplitWidth) { try { const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); this.queueElement.style.width = `${restoreWidth}px`; this.queueElement.style.display = ''; if (this.splitterElement) this.splitterElement.style.display = ''; localStorage.setItem('model_resolver_split_w', String(restoreWidth)); } catch (e) { } this.queueCollapsed = false; try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { } this.updateQueueVisibility(); this.updateQueuePanel(); this._splitEdgeOpen = false; this._pendingSplitWidth = null; this._lastSplitDragApply = 0; this._splitPreviewCollapsed = false; try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } this._splitPreviousCursor = null; this._splitEdgeOpenedPastThreshold = false; setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); return; } const shouldCollapse = !!this._splitPreviewCollapsed; if (shouldCollapse) { try { const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320)); this.queueElement.style.width = `${restoreWidth}px`; this.queueElement.style.display = ''; localStorage.setItem('model_resolver_split_w', String(restoreWidth)); } catch (e) { } this._pendingSplitWidth = null; this._lastSplitDragApply = 0; this._splitPreviewCollapsed = false; this._splitEdgeOpen = false; this._splitEdgeOpenedPastThreshold = false; try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } this._splitPreviousCursor = null; this.setQueueCollapsed(true); setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); return; } if (this.queueElement) this.queueElement.style.display = ''; try { const rect = this.queueElement.getBoundingClientRect(); localStorage.setItem('model_resolver_split_w', String(Math.round(rect.width))); } catch (e) { } this._pendingSplitWidth = null; this._lastSplitDragApply = 0; this._splitPreviewCollapsed = false; this._splitEdgeOpen = false; this._splitEdgeOpenedPastThreshold = false; try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { } if (this._splitPreviousCursor !== null) { try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { } this._splitPreviousCursor = null; } setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0); }, /** * Queue a resolution for later batch apply */ queueResolution(missing, resolvedModel) { if (!resolvedModel) { this.showNotification('No model selected', 'error'); return; } const resolution = { node_id: missing.node_id, widget_index: missing.widget_index, resolved_path: resolvedModel.path, category: missing.category, resolved_model: resolvedModel, original_path: missing.original_path, subgraph_id: missing.subgraph_id, is_top_level: missing.is_top_level, node_type: missing.node_type, node_label: missing.subgraph_name || missing.node_type }; const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`; if (this.pendingIndex.has(key)) { // replace existing selection for this slot const idx = this.pendingIndex.get(key); this.pendingResolutions[idx] = resolution; } else { this.pendingIndex.set(key, this.pendingResolutions.length); this.pendingResolutions.push(resolution); } this.savePendingQueueForActiveWorkflow(); // Update selected bar UI this.updateSelectedBarForMissing?.(missing); this.updateQueuePanel(); this.updateApplyPendingButton(); }, /** * Apply all pending resolutions in batch */ async applyPendingResolutions() { const list = this.pendingResolutions || []; if (!list.length) { this.showNotification('No selections queued', 'error'); return; } try { const workflow = this.getCurrentWorkflow(); if (!workflow) { this.showNotification('No workflow loaded', 'error'); return; } const response = await api.fetchApi('/model_resolver/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow, resolutions: list }) }); if (!response.ok) throw new Error(`API error: ${response.status}`); const data = await response.json(); if (data.success) { await this.updateWorkflowInComfyUI(data.workflow); this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success'); // Clear queue and refresh analysis this.pendingResolutions = []; this.pendingIndex = new Map(); this.savePendingQueueForActiveWorkflow(); this.updateApplyPendingButton(); this.updateQueuePanel(); await this.loadWorkflowData(data.workflow, { force: true }); } else { this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error'); } } catch (e) { console.error('Model Resolver: applyPendingResolutions error', e); this.showNotification('Error applying selections: ' + e.message, 'error'); } }, updateApplyPendingButton() { if (!this.applyPendingBtn) return; const count = this.pendingResolutions?.length || 0; const isEmpty = count === 0; const tooltip = count > 0 ? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.` : 'Apply the model links you selected from local matches or search results.'; this.applyPendingBtn.textContent = `Apply Selected (${count})`; this.setTooltip(this.applyPendingBtn, tooltip); this.applyPendingBtn.disabled = isEmpty; this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty)); this.applyPendingBtn.classList.toggle('mr-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('mr-btn-is-disabled', totalCount === 0); } if (this.searchMenuButton) { this.searchMenuButton.textContent = this.batchSearchRunning ? (this.batchSearchCancelRequested ? 'Stopping...' : 'Searching...') : 'Search'; this.searchMenuButton.classList.toggle('mr-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('mr-btn-danger', activeCount > 0); this.downloadMenuButton.classList.toggle('mr-btn-download', activeCount === 0); this.downloadMenuButton.classList.toggle('mr-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})`; } if (this.linkMenuButton) { this.linkMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && pendingCount === 0); } this.updateFooterMenuItemVisibility?.(); }, getMissingModelsForPendingResolutions() { const pending = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; if (!pending.length) return []; const pendingKeys = new Set(pending.map(resolution => this.getMissingModelKey(resolution))); return (this.missingModels || []).filter(missing => pendingKeys.has(this.getMissingModelKey(missing))); }, getQueueExactLocalMatchPreviewModels() { const selected = this.getSelectedMissingModels(); const targets = selected.length ? selected : (this.missingModels || []); return targets.filter(missing => this.getBestLocalMatch(missing, 100)?.model); }, getDownloadableBatchPreviewModels(mode = 'all') { const targets = mode === 'selected' ? this.getSelectedMissingModels() : (this.missingModels || []); return targets.filter(missing => { if (this.getBestLocalMatch(missing, 100)) return false; return Boolean(this.getBestDownloadSourceForMissing(missing)?.url); }); }, getActiveDownloadMissingModels() { const activeKeys = new Set( Object.values(this.activeDownloads || {}) .map(info => info?.missing) .filter(Boolean) .map(missing => this.getMissingModelKey(missing)) ); if (!activeKeys.size) return []; return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing))); }, previewFooterMenuModels(models = []) { const keys = new Set( (Array.isArray(models) ? models : []) .filter(Boolean) .map(missing => this.getMissingModelKey(missing)) ); this.contentElement?.querySelectorAll?.('.mr-missing-list-row')?.forEach(row => { const key = row.getAttribute('data-missing-key'); row.classList.toggle('is-menu-preview', Boolean(key && keys.has(key))); }); }, clearFooterMenuPreview() { this.contentElement?.querySelectorAll?.('.mr-missing-list-row.is-menu-preview')?.forEach(row => { row.classList.remove('is-menu-preview'); }); }, createFooterMenu(name, label, items = [], buttonClass = 'mr-btn-secondary') { const button = $el(`button.mr-btn.${buttonClass}.mr-footer-btn.mr-footer-menu-button`, { type: 'button', 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: (event) => { event.stopPropagation(); this.hideTooltip?.(); if (button.classList.contains('mr-btn-is-disabled')) { 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 menuChildren = items.map(item => { const isDivider = item === 'divider' || item?.type === 'divider'; let element; if (isDivider) { element = $el("div.mr-footer-menu-divider", {}); } else if (item?.type === 'checkbox') { const input = $el("input.mr-footer-menu-checkbox-input", { type: 'checkbox', checked: Boolean(item.checked?.()) }); element = $el("label.mr-footer-menu-item.mr-footer-menu-checkbox", { role: 'menuitemcheckbox', 'aria-checked': input.checked ? 'true' : 'false', onclick: (event) => { event.stopPropagation(); } }, [ input, $el("span", { textContent: item.label }) ]); input.addEventListener('change', () => { item.action?.(input.checked); element.setAttribute('aria-checked', input.checked ? 'true' : 'false'); }); } else { const attributes = { type: 'button', role: 'menuitem', onclick: (event) => { event.stopPropagation(); if (element.disabled || element.classList.contains('mr-btn-is-disabled')) return; item.action(); } }; if (item.id) attributes.id = item.id; if (item.ariaLabel) attributes['aria-label'] = item.ariaLabel; element = $el("button.mr-footer-menu-item", attributes, [$el("span", { textContent: item.label })]); } if (item?.className) { element.classList.add(...String(item.className).split(/\s+/).filter(Boolean)); } if (item?.refName) { this[item.refName] = element; } if (!isDivider && typeof item?.previewModels === 'function') { const showPreview = () => { if (element.disabled || element.classList.contains('mr-btn-is-disabled')) { this.clearFooterMenuPreview(); return; } try { this.previewFooterMenuModels(item.previewModels()); } catch (error) { console.warn('Model Resolver: footer menu preview failed:', error); this.clearFooterMenuPreview(); } }; element.addEventListener('mouseenter', showPreview); element.addEventListener('focus', showPreview); element.addEventListener('mouseleave', () => this.clearFooterMenuPreview()); element.addEventListener('blur', () => this.clearFooterMenuPreview()); } if (typeof item?.visibleWhen === 'function' || typeof item?.disabledWhen === 'function') { if (!this.footerMenuItems) this.footerMenuItems = new Map(); if (!this.footerMenuItems.has(name)) this.footerMenuItems.set(name, []); this.footerMenuItems.get(name).push({ element, visibleWhen: item.visibleWhen, disabledWhen: item.disabledWhen }); if (typeof item.visibleWhen === 'function') { element.style.display = item.visibleWhen() ? '' : 'none'; } if (typeof item.disabledWhen === 'function') { const disabled = item.disabledWhen(); element.disabled = disabled; element.setAttribute('aria-disabled', String(disabled)); element.classList.toggle('mr-btn-is-disabled', disabled); } } return element; }); const menu = $el("div.mr-footer-menu", { role: 'menu' }, menuChildren); this.footerMenuButtons.set(name, button); this.footerMenus.set(name, menu); return $el("div.mr-footer-menu-wrap", {}, [button, menu]); }, updateFooterMenuItemVisibility() { if (!this.footerMenuItems) return; for (const entries of this.footerMenuItems.values()) { for (const entry of entries) { if (typeof entry.visibleWhen === 'function') { entry.element.style.display = entry.visibleWhen() ? '' : 'none'; } if (typeof entry.disabledWhen === 'function') { const disabled = entry.disabledWhen(); entry.element.disabled = disabled; entry.element.setAttribute('aria-disabled', String(disabled)); entry.element.classList.toggle('mr-btn-is-disabled', disabled); } } } }, createFooter() { this.footerMenus = new Map(); this.footerMenuButtons = new Map(); this.footerMenuItems = new Map(); this.linkMenuButton = null; this.linkMenuWrap = null; this.queueExactButton = null; this.applyPendingBtn = null; this.clearSelectedButton = null; this.autoResolveButton = null; this.downloadMenuButton = null; this.downloadAllButton = null; const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ { label: 'Select All', previewModels: () => this.missingModels || [], action: () => this.selectBatchMissingModels('all') }, { label: 'Select None', previewModels: () => this.getSelectedMissingModels(), action: () => this.selectBatchMissingModels('none') }, { label: 'Invert Selection', previewModels: () => { const selectedKeys = this.batchSelectedMissingKeys || new Set(); return (this.missingModels || []).filter(missing => !selectedKeys.has(this.getMissingModelKey(missing))); }, action: () => this.selectBatchMissingModels('invert') }, 'divider', { label: 'Select Exact Local', previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.selectBatchMissingModels('exact') }, { label: 'Select No Exact Local', previewModels: () => this.getMissingWithoutExactLocalMatches(), action: () => this.selectBatchMissingModels('no_exact') }, { label: 'Select Partial Local', previewModels: () => this.getMissingWithPartialLocalMatches(), action: () => this.selectBatchMissingModels('partial') }, { label: 'Select No Local Match', previewModels: () => this.getMissingWithoutLocalMatches(), action: () => this.selectBatchMissingModels('no_local') }, 'divider', { label: 'Select With Download Source', previewModels: () => this.getMissingWithDownloadSources(), action: () => this.selectBatchMissingModels('downloadable') }, { label: 'Select Without Download Source', previewModels: () => this.getMissingWithoutDownloadSources(), action: () => this.selectBatchMissingModels('no_download') }, 'divider', { label: 'Select Searched', previewModels: () => this.getSearchedMissingModels(), action: () => this.selectBatchMissingModels('searched') }, { label: 'Select Unsearched', previewModels: () => this.getUnsearchedMissingModels(), action: () => this.selectBatchMissingModels('unsearched') } ]); this.selectMenuButton = this.footerMenuButtons.get('select'); this.selectMenuWrap = selectMenu; const hasSelectedMissingModels = () => this.getSelectedMissingModels().length > 0; const searchBatchOptions = () => ({ forceSearch: Boolean(this.forceBatchSearch) }); const searchMenu = this.createFooterMenu('search', 'Search', [ { label: 'Stop Searching', className: 'mr-footer-menu-danger', visibleWhen: () => this.batchSearchRunning, action: () => this.stopBatchSearch() }, { type: 'divider', visibleWhen: () => this.batchSearchRunning }, { type: 'checkbox', label: 'Force search (ignore cache)', visibleWhen: () => !this.batchSearchRunning, checked: () => Boolean(this.forceBatchSearch), action: (checked) => { this.forceBatchSearch = Boolean(checked); } }, { type: 'divider', visibleWhen: () => !this.batchSearchRunning }, { label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all', searchBatchOptions()) }, { label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.missingModels || [], action: () => this.searchMissingBatch('all', 'all', searchBatchOptions()) }, { label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.getUnsearchedMissingModels(), action: () => this.searchMissingBatch('unsearched', 'all', searchBatchOptions()) }, { type: 'divider', visibleWhen: () => !this.batchSearchRunning }, { label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local', searchBatchOptions()) }, { label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai', searchBatchOptions()) }, { label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface', searchBatchOptions()) }, { label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive', searchBatchOptions()) }, { label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive', searchBatchOptions()) } ]); this.searchMenuButton = this.footerMenuButtons.get('search'); this.searchMenuWrap = searchMenu; const hasExactLocalMatches = () => this.getMissingWithExactLocalMatches(this.missingModels || []).length > 0; const linkMenu = this.createFooterMenu('link', 'Link', [ { label: 'Queue Exact', refName: 'queueExactButton', ariaLabel: 'Queue exact local matches', previewModels: () => this.getQueueExactLocalMatchPreviewModels(), action: () => this.queueExactLocalMatchesBatch('selected') }, { label: 'Apply Selected (0)', id: 'apply-pending-resolutions', refName: 'applyPendingBtn', ariaLabel: 'Apply selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { if ((this.pendingResolutions?.length || 0) === 0) return; this.applyPendingResolutions(); } }, { label: 'Clear All Selected', refName: 'clearSelectedButton', ariaLabel: 'Clear all queued selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { this.clearAllQueued(); this.closeFooterMenus(); } }, { type: 'divider', visibleWhen: hasExactLocalMatches }, { label: 'Auto-Link 100%', refName: 'autoResolveButton', ariaLabel: 'Auto-link all 100 percent local matches', visibleWhen: hasExactLocalMatches, previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.autoResolve100Percent() } ]); this.linkMenuButton = this.footerMenuButtons.get('link'); this.linkMenuWrap = linkMenu; this.linkMenuButton.setAttribute('aria-label', 'Local link actions'); this.setTooltip(this.linkMenuButton, 'Queue exact local matches, apply queued links, or auto-link 100% matches.'); 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.'); this.setTooltip(this.clearSelectedButton, 'Clear all queued model links without changing the workflow.'); this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.'); this.updateApplyPendingButton(); const downloadMenu = this.createFooterMenu('download', 'Download', [ { label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getDownloadableBatchPreviewModels('selected'), action: () => this.downloadMissingBatch('selected') }, { label: 'Download All With Sources', previewModels: () => this.getDownloadableBatchPreviewModels('all'), action: () => this.downloadMissingBatch('all') }, 'divider', { label: 'Cancel Downloads', previewModels: () => this.getActiveDownloadMissingModels(), action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } ], 'mr-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.mr-footer", {}, [ selectMenu, searchMenu, linkMenu, downloadMenu ]); }, 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 Resolver: tab content animation failed', error); } }, /** * Handle click on Download All / Cancel All button */ handleDownloadAllClick() { if (Object.keys(this.activeDownloads).length > 0) { // Cancel all active downloads this.cancelAllDownloads(); } else { // Start downloading all missing this.downloadAllMissing(); } }, /** * Cancel all active downloads */ async cancelAllDownloads() { const downloadIds = Object.keys(this.activeDownloads); if (downloadIds.length === 0) return; this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info'); for (const downloadId of downloadIds) { try { await api.fetchApi(`/model_resolver/cancel/${downloadId}`, { method: 'POST' }); } catch (error) { console.error('Model Resolver: Error cancelling download:', error); } } }, /** * Update the Download All button state based on active downloads */ 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) { this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`; this.downloadAllButton.setAttribute( 'data-tooltip', `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.` ); this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads'); this.downloadAllButton.classList.remove('mr-btn-download'); this.downloadAllButton.classList.add('mr-btn-danger'); } else { this.downloadAllButton.innerHTML = `☁ Download All Missing`; this.downloadAllButton.setAttribute( 'data-tooltip', 'Download every missing model that has a known download source.' ); this.downloadAllButton.setAttribute('aria-label', 'Download all missing models'); this.downloadAllButton.classList.remove('mr-btn-danger'); this.downloadAllButton.classList.add('mr-btn-download'); } } };