From f2cd4cde302795315f34d8e8f5fdf0b2a25b3eca Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 23 Jun 2026 04:16:38 +0200 Subject: [PATCH] feat: replace flat subfolder list with floating folder browser for download targets Refactor populateSubfolderOptions into renderDownloadFolderBrowser to show a persistent floating panel with an expandable folder tree grouped by base directory. Includes live preview of the selected target path, manual typed-folder rows, and window/viewport-aware positioning. Prevents duplicate portal mounts and cleans up resize/scroll bindings on hide. --- web/css/resolver-main.css | 229 +++++++++++ .../search/download_target_methods.js | 362 +++++++++++++++++- 2 files changed, 581 insertions(+), 10 deletions(-) diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 2f671fc..fc48822 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -5661,6 +5661,13 @@ border-radius: var(--mr-missing-radius, 7px); box-shadow: 0 16px 36px rgba(0,0,0,0.3); } +.mr-download-target-list.mr-download-target-floating { + position: fixed; + top: auto; + left: auto; + right: auto; + z-index: var(--mr-z-context-menu); +} .mr-download-target-option { padding: 8px 10px; cursor: pointer; @@ -5675,6 +5682,228 @@ .mr-download-target-option.is-highlighted { background: rgba(114,211,138,0.12); } +.mr-download-target-list.mr-download-folder-browser { + right: auto; + width: min(560px, calc(100vw - 48px)); + min-width: 100%; + max-height: none; + overflow: hidden; + padding: 0; +} +.mr-folder-browser-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255,255,255,0.075); + background: rgba(255,255,255,0.025); +} +.mr-folder-browser-title { + color: var(--mr-text); + font-size: 12px; + font-weight: 750; + line-height: 1.25; +} +.mr-folder-browser-subtitle, +.mr-folder-browser-category, +.mr-folder-browser-preview-label, +.mr-folder-browser-root-path, +.mr-folder-browser-count, +.mr-folder-browser-root-count { + color: var(--mr-text-muted); + font-size: 10px; + line-height: 1.3; +} +.mr-folder-browser-category { + max-width: 160px; + padding: 3px 7px; + border: 1px solid rgba(255,255,255,0.09); + border-radius: 999px; + background: rgba(255,255,255,0.035); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mr-folder-browser-preview { + display: grid; + gap: 3px; + padding: 9px 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.mr-folder-browser-preview-path { + color: var(--mr-text); + font-size: 11px; + line-height: 1.35; + word-break: break-all; +} +.mr-folder-browser-scroll { + max-height: 300px; + overflow: auto; + padding: 6px; +} +.mr-folder-browser-root { + border: 1px solid rgba(255,255,255,0.065); + border-radius: 8px; + background: rgba(255,255,255,0.018); +} +.mr-folder-browser-root + .mr-folder-browser-root { + margin-top: 6px; +} +.mr-folder-browser-root-head, +.mr-folder-browser-manual { + width: 100%; + border: 0; + color: var(--mr-text); + background: transparent; + text-align: left; + cursor: pointer; +} +.mr-folder-browser-root-head { + display: flex; + align-items: center; + gap: 7px; + min-height: 30px; + padding: 6px 8px; +} +.mr-folder-browser-root-head:hover, +.mr-folder-browser-manual:hover, +.mr-folder-browser-row:hover { + background: rgba(114,211,138,0.095); +} +.mr-folder-browser-root-title { + min-width: 0; + flex: 1; + font-size: 11px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mr-folder-browser-root-path { + padding: 0 10px 7px 31px; + word-break: break-all; +} +.mr-folder-browser-tree, +.mr-folder-browser-children { + display: none; +} +.mr-folder-browser-tree.is-expanded, +.mr-folder-browser-children.is-expanded { + display: block; +} +.mr-folder-browser-tree { + padding: 2px 4px 6px; +} +.mr-folder-browser-children { + margin-left: 18px; +} +.mr-folder-browser-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 4px 7px; + border-radius: 6px; + cursor: pointer; + color: var(--mr-text); +} +.mr-folder-browser-row.is-selected { + background: rgba(114,211,138,0.15); + box-shadow: inset 0 0 0 1px rgba(114,211,138,0.32); +} +.mr-folder-browser-toggle { + width: 18px; + height: 18px; + min-width: 18px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--mr-text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.mr-folder-browser-toggle:hover { + background: rgba(255,255,255,0.06); + color: var(--mr-text); +} +.mr-folder-browser-toggle-empty { + pointer-events: none; + opacity: 0; +} +.mr-folder-browser-chevron { + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid currentColor; + transition: transform 140ms ease; +} +.mr-folder-browser-toggle.is-expanded .mr-folder-browser-chevron, +.mr-folder-browser-root-head.is-expanded .mr-folder-browser-chevron { + transform: rotate(90deg); +} +.mr-folder-browser-folder-icon { + width: 16px; + height: 16px; + min-width: 16px; + color: rgba(114,211,138,0.95); + display: inline-flex; + align-items: center; + justify-content: center; +} +.mr-folder-browser-svg { + width: 15px; + height: 15px; +} +.mr-folder-browser-name { + min-width: 0; + flex: 1; + color: var(--mr-text); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mr-folder-browser-count, +.mr-folder-browser-root-count { + padding: 2px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.045); +} +.mr-folder-browser-manual { + display: grid; + gap: 2px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.mr-folder-browser-manual-title { + color: var(--mr-text); + font-size: 11px; + font-weight: 700; +} +.mr-folder-browser-manual-path { + color: var(--mr-text-muted); + font-size: 10px; + word-break: break-all; +} +.mr-folder-browser-empty { + padding: 24px 12px; + color: var(--mr-text-muted); + text-align: center; + font-size: 11px; +} +@media (max-width: 720px) { + .mr-download-target-list.mr-download-folder-browser { + width: min(100%, calc(100vw - 32px)); + } + .mr-folder-browser-scroll { + max-height: 260px; + } +} /* Missing detail panel polish */ #model-resolver-modal .mr-missing-detail-pane { diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index 661568b..01b89e8 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -1514,13 +1514,109 @@ export const downloadTargetMethods = { const subfolderEl = container.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`); const suggestBtn = container.querySelector(`#download-subfolder-suggest-${missing.node_id}-${missing.widget_index}`); const categoryListEl = container.querySelector(`#download-category-list-${missing.node_id}-${missing.widget_index}`); - const listEl = container.querySelector(`#download-subfolder-list-${missing.node_id}-${missing.widget_index}`); + const listEl = container.querySelector(`#download-subfolder-list-${missing.node_id}-${missing.widget_index}`) + || document.getElementById(`download-subfolder-list-${missing.node_id}-${missing.widget_index}`); if (!categoryEl || !subfolderEl || !listEl) return; + document.querySelectorAll('.mr-download-target-list[data-ml-floating-portal="true"]').forEach(existing => { + if (existing !== listEl && existing.id === listEl.id) { + existing.remove(); + } + }); + if (listEl.dataset.mlFloatingPortal !== 'true') { + listEl.dataset.mlFloatingPortal = 'true'; + document.body.appendChild(listEl); + } + listEl.classList.add('mr-download-target-floating'); + this.enableWheelScrollChaining(listEl); if (categoryListEl) { this.enableWheelScrollChaining(categoryListEl); } + if (listEl.dataset.mlBrowserMouseBound !== 'true') { + listEl.dataset.mlBrowserMouseBound = 'true'; + listEl.addEventListener('mousedown', (event) => { + event.preventDefault(); + }); + } + let floatingPositionCleanup = null; + + const cleanupFloatingPositioning = () => { + if (floatingPositionCleanup) { + floatingPositionCleanup(); + floatingPositionCleanup = null; + } + }; + + const hideFloatingSubfolderList = () => { + cleanupFloatingPositioning(); + listEl.style.display = 'none'; + }; + + const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max); + + const positionFloatingSubfolderList = () => { + if (listEl.style.display === 'none') return; + + const rect = subfolderEl.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); + if (rect.bottom < viewportPadding || rect.top > viewportHeight - viewportPadding) { + hideFloatingSubfolderList(); + return; + } + + const availableWidth = Math.max(260, viewportWidth - viewportPadding * 2); + const targetWidth = Math.min(560, availableWidth, Math.max(rect.width, 420)); + 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 < 260 && spaceAbove > spaceBelow; + const availableHeight = Math.max(180, Math.min(380, openAbove ? spaceAbove : spaceBelow)); + + listEl.style.position = 'fixed'; + listEl.style.left = `${left}px`; + listEl.style.right = 'auto'; + listEl.style.width = `${targetWidth}px`; + listEl.style.minWidth = `${Math.min(rect.width, targetWidth)}px`; + listEl.style.maxWidth = `${availableWidth}px`; + + const scrollEl = listEl.querySelector('.mr-folder-browser-scroll'); + if (scrollEl) { + const chromeHeight = Math.max(0, listEl.offsetHeight - scrollEl.offsetHeight); + scrollEl.style.maxHeight = `${Math.max(140, availableHeight - chromeHeight)}px`; + } + + const popupHeight = listEl.offsetHeight; + const top = openAbove + ? Math.max(viewportPadding, rect.top - popupHeight - gap) + : Math.min(rect.bottom + gap, viewportHeight - viewportPadding - Math.min(popupHeight, availableHeight)); + listEl.style.top = `${top}px`; + }; + + const bindFloatingPositioning = () => { + cleanupFloatingPositioning(); + const updatePosition = () => positionFloatingSubfolderList(); + window.addEventListener('resize', updatePosition, true); + window.addEventListener('scroll', updatePosition, true); + floatingPositionCleanup = () => { + window.removeEventListener('resize', updatePosition, true); + window.removeEventListener('scroll', updatePosition, true); + }; + }; + + const showFloatingSubfolderList = () => { + listEl.style.display = 'block'; + positionFloatingSubfolderList(); + bindFloatingPositioning(); + requestAnimationFrame(positionFloatingSubfolderList); + }; const renderOptions = (targetEl, values, onSelect) => { const options = values.map(value => ( @@ -1558,6 +1654,252 @@ export const downloadTargetMethods = { }); }; + const normalizeSubfolderPath = (value = '') => String(value || '') + .replace(/\//g, '\\') + .split('\\') + .map(part => part.trim()) + .filter(part => part && part !== '.') + .join('\\'); + + const getFolderBrowserExpandedSet = (category = '') => { + if (!this.downloadFolderBrowserExpandedGroups) { + this.downloadFolderBrowserExpandedGroups = new Map(); + } + const key = this.normalizeDownloadCategory(category || 'unknown'); + if (!this.downloadFolderBrowserExpandedGroups.has(key)) { + this.downloadFolderBrowserExpandedGroups.set(key, new Set()); + } + return this.downloadFolderBrowserExpandedGroups.get(key); + }; + + const getBaseDirectoryLabel = (baseDirectory = '') => { + const clean = String(baseDirectory || '').replace(/[\\\/]+$/, ''); + if (!clean) return 'Default root'; + return clean.split(/[\\\/]+/).filter(Boolean).pop() || clean; + }; + + const buildFolderTree = (entries = []) => { + const root = new Map(); + entries.forEach(entry => { + const value = normalizeSubfolderPath(entry.value); + if (!value) return; + + const parts = value.split('\\').filter(Boolean); + let current = root; + let currentPath = ''; + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}\\${part}` : part; + if (!current.has(part)) { + current.set(part, { + name: part, + path: currentPath, + children: new Map(), + entry: null + }); + } + const node = current.get(part); + if (index === parts.length - 1) { + node.entry = entry; + } + current = node.children; + }); + }); + return root; + }; + + const countTreeNodes = (nodeMap) => { + let count = 0; + nodeMap.forEach(node => { + count += 1 + countTreeNodes(node.children); + }); + return count; + }; + + const renderFolderTreeNodes = ( + nodeMap, + rootGroup, + expandedSet, + filter, + selectedValue, + selectedBaseDirectory + ) => { + return Array.from(nodeMap.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(node => { + const hasChildren = node.children.size > 0; + const stateKey = `node:${rootGroup.key}:${node.path.toLowerCase()}`; + const childCount = countTreeNodes(node.children); + const nodeValue = node.entry?.value || node.path; + const nodeBaseDirectory = node.entry?.baseDirectory || rootGroup.baseDirectory || ''; + const isSelected = normalizeSubfolderPath(nodeValue).toLowerCase() === selectedValue.toLowerCase() + && String(nodeBaseDirectory || '') === String(selectedBaseDirectory || ''); + const shouldExpand = hasChildren && ( + Boolean(filter) + || expandedSet.has(stateKey) + || selectedValue.toLowerCase().startsWith(`${node.path.toLowerCase()}\\`) + ); + const toggle = hasChildren + ? `` + : ``; + const children = hasChildren + ? `
${renderFolderTreeNodes(node.children, rootGroup, expandedSet, filter, selectedValue, selectedBaseDirectory)}
` + : ''; + + return ` +
+
+ ${toggle} + ${getSvgIcon('folderOpen', 'currentColor', 'mr-folder-browser-svg')} + ${this.escapeHtml(node.name)} + ${childCount ? `${childCount}` : ''} +
+ ${children} +
+ `; + }) + .join(''); + }; + + const renderDownloadFolderBrowser = (targetEl, folders, filterText, onSelect) => { + const rawFilter = String(filterText || '').trim(); + const filter = rawFilter.toLowerCase(); + const normalizedFilter = filter.replace(/[\/]+/g, '\\'); + const category = this.getDropdownValue(categoryEl); + const expandedSet = getFolderBrowserExpandedSet(category); + const selectedValue = normalizeSubfolderPath(subfolderEl.value || ''); + const selectedBaseDirectory = subfolderEl.dataset.baseDirectory || ''; + const folderEntries = folders.map(folder => ({ + value: normalizeSubfolderPath(this.getSubfolderOptionValue(folder)), + label: this.getSubfolderOptionLabel(folder), + baseDirectory: this.getSubfolderOptionBaseDirectory(folder), + searchText: this.getSubfolderOptionSearchText(folder) + })).filter(entry => entry.value); + const filteredEntries = normalizedFilter + ? folderEntries.filter(entry => ( + entry.searchText.includes(filter) + || entry.value.toLowerCase().includes(normalizedFilter) + || entry.label.toLowerCase().includes(filter) + )) + : folderEntries; + const rootGroupsMap = new Map(); + filteredEntries.forEach(entry => { + const baseDirectory = entry.baseDirectory || ''; + const key = baseDirectory || '__default__'; + if (!rootGroupsMap.has(key)) { + rootGroupsMap.set(key, { + key, + baseDirectory, + label: getBaseDirectoryLabel(baseDirectory), + entries: [] + }); + } + rootGroupsMap.get(key).entries.push(entry); + }); + const rootGroups = Array.from(rootGroupsMap.values()) + .sort((a, b) => a.label.localeCompare(b.label)); + const selectedContext = this.getDownloadTargetFolderContext( + category, + selectedValue, + selectedBaseDirectory + ); + const previewPath = selectedContext?.download_directory + || selectedContext?.folder_path + || selectedValue + || this.getDownloadTargetBaseDirectory(category) + || ''; + const hasExactTypedMatch = rawFilter + ? folderEntries.some(entry => entry.value.toLowerCase() === normalizeSubfolderPath(rawFilter).toLowerCase()) + : true; + const typedPath = normalizeSubfolderPath(rawFilter); + const clearRow = selectedValue + ? `` + : ''; + const manualRow = typedPath && !hasExactTypedMatch + ? `` + : ''; + const rootHtml = rootGroups.map(group => { + const tree = buildFolderTree(group.entries); + const stateKey = `root:${group.key}`; + const isExpanded = Boolean(normalizedFilter) + || rootGroups.length === 1 + || expandedSet.has(stateKey) + || (selectedBaseDirectory && selectedBaseDirectory === group.baseDirectory); + const treeHtml = renderFolderTreeNodes( + tree, + group, + expandedSet, + normalizedFilter, + selectedValue, + selectedBaseDirectory + ); + + return ` +
+ + ${group.baseDirectory ? `
${this.escapeHtml(group.baseDirectory)}
` : ''} +
${treeHtml}
+
+ `; + }).join(''); + + targetEl.classList.add('mr-download-folder-browser'); + targetEl.innerHTML = ` +
+
+
Browse folders
+
${filteredEntries.length} of ${folderEntries.length} folders
+
+
${this.escapeHtml(this.getCategoryDisplayName(category))}
+
+
+ Target folder path + ${this.escapeHtml(previewPath)} +
+ ${clearRow} + ${manualRow} +
+ ${rootHtml || `
No folders match this filter.
`} +
+ `; + showFloatingSubfolderList(); + + targetEl.querySelectorAll('[data-browser-action="toggle"]').forEach(button => { + button.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + const stateKey = decodeURIComponent(button.dataset.stateKey || ''); + if (!stateKey) return; + if (expandedSet.has(stateKey)) { + expandedSet.delete(stateKey); + } else { + expandedSet.add(stateKey); + } + renderDownloadFolderBrowser(targetEl, folders, filterText, onSelect); + }); + }); + + targetEl.querySelectorAll('[data-browser-action="select"]').forEach(row => { + row.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + const value = decodeURIComponent(row.dataset.value || ''); + const baseDirectory = decodeURIComponent(row.dataset.baseDirectory || ''); + onSelect(value, '', baseDirectory); + hideFloatingSubfolderList(); + }); + }); + }; + const populateCategoryOptions = (filterText = '') => { if (!categoryListEl) return; const filter = String(filterText || '').trim().toLowerCase(); @@ -1586,22 +1928,18 @@ export const downloadTargetMethods = { }); subfolderEl.dataset.baseDirectory = ''; listEl.innerHTML = ''; - listEl.style.display = 'none'; + hideFloatingSubfolderList(); this.syncDownloadTargetFolderContext(categoryEl, subfolderEl); this.applySuggestedDownloadSubfolder(missing, categoryEl, subfolderEl); }); }; const populateSubfolderOptions = async (filterText = '') => { - const filter = (filterText || '').toLowerCase(); const category = this.getDropdownValue(categoryEl); await this.ensureDownloadSubfoldersLoaded(category); const folders = this.getAvailableSubfolders(category); - const filtered = filter - ? folders.filter(folder => this.getSubfolderOptionSearchText(folder).includes(filter)) - : folders; - renderOptions(listEl, filtered, (value, _label, baseDirectory) => { + renderDownloadFolderBrowser(listEl, folders, filterText, (value, _label, baseDirectory) => { subfolderEl.value = value; subfolderEl.dataset.baseDirectory = baseDirectory || ''; this.saveDownloadTargetSelection(missing, { @@ -1616,7 +1954,11 @@ export const downloadTargetMethods = { const hideList = (targetEl) => { setTimeout(() => { - targetEl.style.display = 'none'; + if (targetEl === listEl) { + hideFloatingSubfolderList(); + } else { + targetEl.style.display = 'none'; + } }, 150); }; @@ -1633,7 +1975,7 @@ export const downloadTargetMethods = { subfolderEl.value = ''; subfolderEl.dataset.baseDirectory = ''; listEl.innerHTML = ''; - listEl.style.display = 'none'; + hideFloatingSubfolderList(); } this.saveDownloadTargetSelection(missing, { category, @@ -1691,7 +2033,7 @@ export const downloadTargetMethods = { try { await this.forceSuggestedDownloadSubfolder(missing, categoryEl, subfolderEl); listEl.innerHTML = ''; - listEl.style.display = 'none'; + hideFloatingSubfolderList(); } finally { await animationDone; suggestBtn.classList.remove('mr-is-suggesting');