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 ` +
+ + ${getSvgIcon('file', 'currentColor', 'mr-folder-browser-svg')} + ${this.escapeHtml(entry.filename)} + ${folderText ? `${this.escapeHtml(folderText)}` : ''} +
+ `; + }) + .join(''); + const renderModelTreeNodes = (node, groupKey, expandedSet, filter, selectedIdentity) => { + const folderHtml = Array.from(node.folders.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(folder => { + const stateKey = `node:${groupKey}:${folder.path.toLowerCase()}`; + const modelCount = countTreeModels(folder); + const shouldExpand = Boolean(filter) || expandedSet.has(stateKey); + return ` +
+ +
+ ${renderModelTreeNodes(folder, groupKey, expandedSet, filter, selectedIdentity)} +
+
+ `; + }) + .join(''); + return `${renderModelRows(node.models || [], selectedIdentity)}${folderHtml}`; + }; + const populateComboOptions = (filterText, options = {}) => { if (!comboList) return; + const previousScrollEl = comboList.querySelector('.mr-local-model-browser-scroll'); + const previousScrollTop = options.preserveScroll && previousScrollEl ? previousScrollEl.scrollTop : 0; const allModels = getAllModels(); - const f = (filterText || '').toLowerCase(); - const filtered = f - ? allModels.filter(m => buildLabel(m).toLowerCase().includes(f)) - : allModels.slice(); - - let html = ''; - for (let i = 0; i < filtered.length; i++) { - const m = filtered[i]; - const label = buildLabel(m); - const folder = getFolder(m); - const isHighlighted = i === highlightIdx; - const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : ''; - html += `
`; - html += `
`; - html += `${this.escapeHtml(label)}`; - html += `
`; - if (folderDisplay) { - html += `
Folder ${this.escapeHtml(folderDisplay)}
`; + const activePreferredCategory = getPreferredModelCategory(); + const allEntries = allModels.map((model, index) => makeModelEntry(model, index, activePreferredCategory)); + const rawFilter = String(filterText || '').trim(); + const filter = rawFilter.toLowerCase(); + const tokens = filter.split(/\s+/).filter(Boolean); + const filteredEntries = allEntries.filter(entry => { + if (!tokens.length) return true; + return tokens.every(token => entry.searchText.includes(token)); + }); + const grouped = new Map(); + filteredEntries.forEach(entry => { + const groupKey = `${entry.category}::${entry.baseDirectory || ''}`; + if (!grouped.has(groupKey)) { + grouped.set(groupKey, { + key: groupKey, + category: entry.category, + label: entry.categoryLabel, + baseDirectory: entry.baseDirectory, + baseLabel: entry.baseLabel, + isPreferred: entry.category === activePreferredCategory, + entries: [] + }); } - html += `
`; - } - comboList.innerHTML = html; + grouped.get(groupKey).entries.push(entry); + }); + const expandedSet = getLocalModelExpandedSet(activePreferredCategory); + const selectedIdentity = getCurrentQueuedModelIdentity(); + const groupsHtml = Array.from(grouped.values()) + .sort((a, b) => { + if (a.isPreferred !== b.isPreferred) return a.isPreferred ? -1 : 1; + const labelCompare = a.label.localeCompare(b.label); + if (labelCompare !== 0) return labelCompare; + return (a.baseLabel || '').localeCompare(b.baseLabel || ''); + }) + .map(group => { + const stateKey = `root:${group.key.toLowerCase()}`; + const collapsedStateKey = `collapsed:${stateKey}`; + const isExpanded = Boolean(filter) + || expandedSet.has(stateKey) + || (group.isPreferred && !expandedSet.has(collapsedStateKey)); + const tree = buildModelTree(group.entries); + return ` +
+ + ${group.baseDirectory ? `
${this.escapeHtml(group.baseDirectory)}
` : ''} +
+ ${renderModelTreeNodes(tree, group.key, expandedSet, filter, selectedIdentity)} +
+
+ `; + }) + .join(''); + const emptyHtml = tokens.length + ? `
No local models match this filter.
` + : `
No local models in the enabled categories.
`; - comboList.querySelectorAll('div[data-idx]').forEach(el => { + comboList.innerHTML = ` +
+ ${groupsHtml || emptyHtml} +
+ `; + + comboList.querySelectorAll('[data-browser-action="toggle"]').forEach(row => { + row.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const scrollElBefore = comboList.querySelector('.mr-local-model-browser-scroll'); + const scrollTop = scrollElBefore ? scrollElBefore.scrollTop : 0; + const stateKey = decodeURIComponent(row.dataset.stateKey || ''); + if (!stateKey) return; + const collapsedStateKey = `collapsed:${stateKey}`; + const isExpanded = row.classList.contains('is-expanded') + || Boolean(row.querySelector('.mr-folder-browser-toggle.is-expanded')); + if (isExpanded) { + expandedSet.delete(stateKey); + expandedSet.add(collapsedStateKey); + } else { + expandedSet.add(stateKey); + expandedSet.delete(collapsedStateKey); + } + populateComboOptions(comboInput?.value || '', { preserveScroll: true }); + const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll'); + if (scrollEl) scrollEl.scrollTop = scrollTop; + positionComboList(); + }); + }); + comboList.querySelectorAll('[data-browser-action="select-model"]').forEach(el => { el.addEventListener('click', () => { - const idx = parseInt(el.dataset.idx, 10); + const idx = parseInt(el.dataset.modelIndex, 10); if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) { const chosenModel = allModels[idx]; if (chosenModel) { @@ -826,19 +1088,15 @@ export const missingBrowserMethods = { } }); }); + const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll'); + if (scrollEl) { + this.enableWheelScrollChaining(scrollEl); + scrollEl.scrollTop = previousScrollTop; + } + positionComboList(); }; if (comboList) { - comboList.addEventListener('pointerdown', () => { - comboListPointerActive = true; - const releaseComboPointer = () => { - setTimeout(() => { - comboListPointerActive = false; - }, 0); - }; - window.addEventListener('pointerup', releaseComboPointer, { once: true, capture: true }); - window.addEventListener('pointercancel', releaseComboPointer, { once: true, capture: true }); - }, { capture: true }); populateComboOptions(''); } @@ -851,15 +1109,24 @@ export const missingBrowserMethods = { showComboList(); populateComboOptions(comboInput.value); }); - comboInput.addEventListener('blur', () => { - setTimeout(() => { - if (!comboList) return; - if (comboListPointerActive || comboList.matches(':hover')) { - return; - } - hideComboList(); - }, 200); - }); + } + + const categoryPriorityEl = getDownloadCategoryElement(); + if (categoryPriorityEl && categoryPriorityEl.dataset.mlLocalModelPriorityBound !== 'true') { + categoryPriorityEl.dataset.mlLocalModelPriorityBound = 'true'; + let lastPriorityCategory = getPreferredModelCategory(); + const refreshLocalModelPriority = () => { + const nextPriorityCategory = getPreferredModelCategory(); + if (nextPriorityCategory === lastPriorityCategory && comboList?.style.display === 'none') { + return; + } + lastPriorityCategory = nextPriorityCategory; + populateComboOptions(comboInput?.value || '', { preserveScroll: true }); + }; + categoryPriorityEl.addEventListener('input', refreshLocalModelPriority); + categoryPriorityEl.addEventListener('change', refreshLocalModelPriority); + categoryPriorityEl.addEventListener('blur', () => setTimeout(refreshLocalModelPriority, 0)); + categoryPriorityEl.addEventListener('mr-download-category-change', refreshLocalModelPriority); } if (comboRefresh) {