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.
This commit is contained in:
Dariusz L
2026-06-23 04:16:38 +02:00
parent 7cd57c05cb
commit f2cd4cde30
2 changed files with 581 additions and 10 deletions
+229
View File
@@ -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 {
+352 -10
View File
@@ -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
? `<button class="mr-folder-browser-toggle ${shouldExpand ? 'is-expanded' : ''}" type="button" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}" aria-label="${shouldExpand ? 'Collapse folder' : 'Expand folder'}"><span class="mr-folder-browser-chevron"></span></button>`
: `<span class="mr-folder-browser-toggle mr-folder-browser-toggle-empty"></span>`;
const children = hasChildren
? `<div class="mr-folder-browser-children ${shouldExpand ? 'is-expanded' : ''}">${renderFolderTreeNodes(node.children, rootGroup, expandedSet, filter, selectedValue, selectedBaseDirectory)}</div>`
: '';
return `
<div class="mr-folder-browser-node">
<div class="mr-folder-browser-row ${isSelected ? 'is-selected' : ''}" data-browser-action="select" data-value="${encodeURIComponent(nodeValue)}" data-base-directory="${encodeURIComponent(nodeBaseDirectory)}">
${toggle}
<span class="mr-folder-browser-folder-icon">${getSvgIcon('folderOpen', 'currentColor', 'mr-folder-browser-svg')}</span>
<span class="mr-folder-browser-name">${this.escapeHtml(node.name)}</span>
${childCount ? `<span class="mr-folder-browser-count">${childCount}</span>` : ''}
</div>
${children}
</div>
`;
})
.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
? `<button class="mr-folder-browser-manual" type="button" data-browser-action="select" data-value="" data-base-directory="">
<span class="mr-folder-browser-manual-title">Use model root</span>
<span class="mr-folder-browser-manual-path">${this.escapeHtml(this.getDownloadTargetBaseDirectory(category) || 'Category root')}</span>
</button>`
: '';
const manualRow = typedPath && !hasExactTypedMatch
? `<button class="mr-folder-browser-manual" type="button" data-browser-action="select" data-value="${encodeURIComponent(typedPath)}" data-base-directory="">
<span class="mr-folder-browser-manual-title">Use typed folder</span>
<span class="mr-folder-browser-manual-path">${this.escapeHtml(typedPath)}</span>
</button>`
: '';
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 `
<div class="mr-folder-browser-root">
<button class="mr-folder-browser-root-head ${isExpanded ? 'is-expanded' : ''}" type="button" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}">
<span class="mr-folder-browser-chevron"></span>
<span class="mr-folder-browser-root-title">${this.escapeHtml(group.label)}</span>
<span class="mr-folder-browser-root-count">${group.entries.length}</span>
</button>
${group.baseDirectory ? `<div class="mr-folder-browser-root-path">${this.escapeHtml(group.baseDirectory)}</div>` : ''}
<div class="mr-folder-browser-tree ${isExpanded ? 'is-expanded' : ''}">${treeHtml}</div>
</div>
`;
}).join('');
targetEl.classList.add('mr-download-folder-browser');
targetEl.innerHTML = `
<div class="mr-folder-browser-head">
<div>
<div class="mr-folder-browser-title">Browse folders</div>
<div class="mr-folder-browser-subtitle">${filteredEntries.length} of ${folderEntries.length} folders</div>
</div>
<div class="mr-folder-browser-category">${this.escapeHtml(this.getCategoryDisplayName(category))}</div>
</div>
<div class="mr-folder-browser-preview">
<span class="mr-folder-browser-preview-label">Target folder path</span>
<span class="mr-folder-browser-preview-path">${this.escapeHtml(previewPath)}</span>
</div>
${clearRow}
${manualRow}
<div class="mr-folder-browser-scroll">
${rootHtml || `<div class="mr-folder-browser-empty">No folders match this filter.</div>`}
</div>
`;
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');