diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index c1a9349..d1f838d 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -2569,6 +2569,49 @@ text-overflow: ellipsis; white-space: nowrap; } +.mr-combo-list.mr-local-model-browser { + box-sizing: border-box; + left: auto; + right: auto; + width: min(760px, calc(100vw - 48px)); + max-height: none; + overflow: hidden; + padding: 0; + z-index: var(--mr-z-context-menu); +} +.mr-local-model-browser-scroll { + box-sizing: border-box; + overflow: auto; + padding: 6px; +} +.mr-local-model-browser .mr-folder-browser-root.is-preferred { + border-color: rgba(114,211,138,0.18); + background: rgba(114,211,138,0.035); +} +.mr-local-model-browser .mr-folder-browser-root-title { + font-size: 11px; +} +.mr-local-model-row { + min-height: 30px; +} +.mr-local-model-file-icon { + color: rgba(120,170,255,0.95); +} +.mr-local-model-folder-tag { + min-width: 0; + max-width: 42%; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.045); + color: var(--mr-text-muted); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mr-local-model-folder-row { + font-weight: 700; +} /* Status Messages */ .mr-status { diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 5db385b..25dce73 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -2369,6 +2369,10 @@ export const downloadTargetMethods = { this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); refreshCategoryTooltip(); this.applySuggestedDownloadSubfolder(missing, categoryEl, subfolderEl); + categoryEl.dispatchEvent(new CustomEvent('mr-download-category-change', { + bubbles: true, + detail: { category: value } + })); }, categoryEl); }; @@ -2414,6 +2418,12 @@ export const downloadTargetMethods = { }); this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); refreshCategoryTooltip(); + if (category !== previousCategory) { + categoryEl.dispatchEvent(new CustomEvent('mr-download-category-change', { + bubbles: true, + detail: { category } + })); + } populateCategoryOptions(typed); }); categoryEl.addEventListener('keydown', (event) => { diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 59c5cf6..cbad42f 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -735,88 +735,350 @@ export const missingBrowserMethods = { const comboInput = container.querySelector(`#combo-input-${comboId}`); const comboList = container.querySelector(`#combo-list-${comboId}`); const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`); - const comboSection = comboInput?.closest('.mr-combo-section') - || comboList?.closest('.mr-combo-section') - || comboRefresh?.closest('.mr-combo-section'); - - if (comboList) { - this.enableWheelScrollChaining(comboList); - } const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : []; - const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`; - const getFolder = (m) => m.path || m.base_directory || ''; - let comboListPointerActive = false; - let outsideComboPointerHandler = null; - const isInsideCombo = (target) => ( - Boolean(target) && ( - Boolean(comboSection?.contains(target)) - || Boolean(comboInput?.contains(target)) - || Boolean(comboList?.contains(target)) - || Boolean(comboRefresh?.contains(target)) - ) - ); - const stopOutsideComboPointerListener = () => { - if (!outsideComboPointerHandler) return; - window.removeEventListener('pointerdown', outsideComboPointerHandler, true); - outsideComboPointerHandler = null; + const normalizeModelPath = (value = '') => String(value || '') + .replace(/\//g, '\\') + .split('\\') + .map(part => part.trim()) + .filter(Boolean) + .join('\\'); + const getModelCategory = (model = {}) => this.normalizeDownloadCategory(model.category || 'unknown') || 'unknown'; + const getDownloadCategoryElement = () => container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`) + || this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`); + const getPreferredModelCategory = () => { + const categoryEl = getDownloadCategoryElement(); + const selectedCategory = this.normalizeDownloadCategory(this.getDropdownValue?.(categoryEl) || categoryEl?.value || ''); + const savedCategory = this.normalizeDownloadCategory(this.getSavedDownloadTargetSelection?.(missing)?.category || ''); + const inferredCategory = this.normalizeDownloadCategory( + missing.category || this.getMissingDownloadCategory?.(missing, 'unknown') || 'unknown' + ); + return selectedCategory || savedCategory || inferredCategory || 'unknown'; }; - const startOutsideComboPointerListener = () => { - if (outsideComboPointerHandler) return; - outsideComboPointerHandler = (event) => { - if (!comboList?.isConnected) { - stopOutsideComboPointerListener(); + const getLocalModelExpandedSet = (preferredCategory = getPreferredModelCategory()) => { + if (!this.localModelBrowserExpandedGroups) { + this.localModelBrowserExpandedGroups = new Map(); + } + const key = preferredCategory || 'all'; + if (!this.localModelBrowserExpandedGroups.has(key)) { + this.localModelBrowserExpandedGroups.set(key, new Set()); + } + return this.localModelBrowserExpandedGroups.get(key); + }; + const getCurrentQueuedModelIdentity = () => { + const key = this.getMissingModelKey(missing); + const idx = this.pendingIndex?.get?.(key); + const selection = Number.isInteger(idx) ? this.pendingResolutions?.[idx] : null; + const model = selection?.resolved_model || {}; + return [ + model.path || selection?.resolved_path || '', + model.relative_path || '', + model.filename || '', + model.category || selection?.category || '' + ].map(value => String(value || '').trim().toLowerCase()).join('::'); + }; + const getModelIdentity = (model = {}) => [ + model.path || '', + model.relative_path || '', + model.filename || '', + model.category || '' + ].map(value => String(value || '').trim().toLowerCase()).join('::'); + const getBaseDirectoryLabel = (baseDirectory = '') => { + const clean = String(baseDirectory || '').replace(/[\\\/]+$/, ''); + if (!clean) return 'Default root'; + return clean.split(/[\\\/]+/).filter(Boolean).pop() || clean; + }; + const makeModelEntry = (model, index, preferredCategory = getPreferredModelCategory()) => { + const category = getModelCategory(model); + const categoryLabel = this.getCategoryDisplayName(category); + const relativePath = normalizeModelPath(model.relative_path || model.filename || ''); + const pathParts = relativePath.split('\\').filter(Boolean); + const filename = pathParts.pop() || model.filename || relativePath || 'model'; + const folderPath = pathParts.join('\\'); + const baseDirectory = String(model.base_directory || ''); + const fullPath = String(model.path || ''); + return { + index, + model, + category, + categoryLabel, + relativePath: relativePath || filename, + filename, + folderPath, + baseDirectory, + baseLabel: getBaseDirectoryLabel(baseDirectory), + fullPath, + isPreferred: category === preferredCategory, + searchText: [ + category, + categoryLabel, + relativePath, + filename, + folderPath, + baseDirectory, + fullPath + ].join(' ').toLowerCase() + }; + }; + const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max); + const cleanupComboPositioning = () => { + if (typeof comboList?._mlFloatingPositionCleanup === 'function') { + comboList._mlFloatingPositionCleanup(); + comboList._mlFloatingPositionCleanup = null; + } + }; + const positionComboList = () => { + if (!comboList || !comboInput || comboList.style.display === 'none') return; + const rect = comboInput.getBoundingClientRect(); + const viewportPadding = 12; + const viewportWidth = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 0); + const viewportHeight = Math.max(240, window.innerHeight || document.documentElement.clientHeight || 0); + const availableWidth = Math.max(280, viewportWidth - viewportPadding * 2); + const targetWidth = Math.min(760, availableWidth, Math.max(rect.width, 520)); + const left = clampNumber( + rect.left, + viewportPadding, + Math.max(viewportPadding, viewportWidth - targetWidth - viewportPadding) + ); + const gap = 6; + const spaceBelow = viewportHeight - rect.bottom - viewportPadding - gap; + const spaceAbove = rect.top - viewportPadding - gap; + const openAbove = spaceBelow < 280 && spaceAbove > spaceBelow; + const availableHeight = Math.max(180, openAbove ? spaceAbove : spaceBelow); + + comboList.style.position = 'fixed'; + comboList.style.left = `${Math.round(left)}px`; + comboList.style.right = 'auto'; + comboList.style.width = `${Math.round(targetWidth)}px`; + comboList.style.maxWidth = `${Math.round(availableWidth)}px`; + comboList.style.maxHeight = `${Math.round(availableHeight)}px`; + + const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll'); + if (scrollEl) { + const chromeHeight = Array.from(comboList.children).reduce((height, child) => { + if (child === scrollEl) return height; + const style = window.getComputedStyle(child); + return height + + child.offsetHeight + + (Number.parseFloat(style.marginTop) || 0) + + (Number.parseFloat(style.marginBottom) || 0); + }, 0); + scrollEl.style.maxHeight = `${Math.max(120, availableHeight - chromeHeight)}px`; + } + + const popupHeight = Math.min(comboList.offsetHeight || availableHeight, availableHeight); + const top = openAbove + ? clampNumber(rect.top - popupHeight - gap, viewportPadding, Math.max(viewportPadding, viewportHeight - viewportPadding - popupHeight)) + : clampNumber(rect.bottom + gap, viewportPadding, Math.max(viewportPadding, viewportHeight - viewportPadding - popupHeight)); + comboList.style.top = `${Math.round(top)}px`; + }; + const bindComboPositioning = () => { + cleanupComboPositioning(); + const updatePosition = (event) => { + if (event?.type === 'scroll' && event.target instanceof Node && comboList?.contains(event.target)) { return; } - if (isInsideCombo(event.target)) { - return; - } - hideComboList(); + positionComboList(); + }; + window.addEventListener('resize', updatePosition, true); + window.addEventListener('scroll', updatePosition, true); + comboList._mlFloatingPositionCleanup = () => { + window.removeEventListener('resize', updatePosition, true); + window.removeEventListener('scroll', updatePosition, true); }; - window.addEventListener('pointerdown', outsideComboPointerHandler, true); }; const showComboList = () => { if (!comboList) return; - comboList.classList.remove('mr-is-hidden'); - comboList.classList.add('mr-is-visible'); - startOutsideComboPointerListener(); + document.querySelectorAll('.mr-combo-list[data-ml-floating-portal="true"]').forEach(existing => { + if (existing !== comboList && existing.id === comboList.id) { + existing.remove(); + } + }); + if (comboList.dataset.mlFloatingPortal !== 'true') { + comboList.dataset.mlFloatingPortal = 'true'; + document.body.appendChild(comboList); + } + comboList.classList.add('mr-local-model-browser', 'mr-download-target-floating'); + comboList.style.display = 'block'; + positionComboList(); + bindComboPositioning(); + requestAnimationFrame(positionComboList); }; const hideComboList = () => { if (!comboList) return; - comboList.classList.remove('mr-is-visible'); - comboList.classList.add('mr-is-hidden'); - stopOutsideComboPointerListener(); + cleanupComboPositioning(); + comboList.style.display = 'none'; }; - - const populateComboOptions = (filterText, highlightIdx = -1) => { + if (comboList) { + this.bindDropdownOutsideDismiss?.(comboList, [comboInput, comboRefresh], hideComboList); + this.enableWheelScrollChaining(comboList); + } + const buildModelTree = (entries = []) => { + const root = { folders: new Map(), models: [] }; + entries.forEach(entry => { + const folderParts = entry.folderPath.split('\\').filter(Boolean); + let current = root; + let currentPath = ''; + folderParts.forEach(part => { + currentPath = currentPath ? `${currentPath}\\${part}` : part; + if (!current.folders.has(part)) { + current.folders.set(part, { + name: part, + path: currentPath, + folders: new Map(), + models: [] + }); + } + current = current.folders.get(part); + }); + current.models.push(entry); + }); + return root; + }; + const countTreeModels = (node) => { + let count = node.models?.length || 0; + node.folders?.forEach(child => { + count += countTreeModels(child); + }); + return count; + }; + const renderModelRows = (entries = [], selectedIdentity = '') => entries + .sort((a, b) => a.filename.localeCompare(b.filename)) + .map(entry => { + const selected = getModelIdentity(entry.model) === selectedIdentity; + const folderText = entry.folderPath || entry.baseLabel || ''; + return ` +
${this.escapeHtml(label)}`;
- html += `