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 + ? `
` + : ''; + + return ` +