diff --git a/web/resolver/search/download_target_methods.js b/web/resolver/search/download_target_methods.js index ff1d907..ef8a499 100644 --- a/web/resolver/search/download_target_methods.js +++ b/web/resolver/search/download_target_methods.js @@ -4,6 +4,7 @@ import { $el } from "../../../../../scripts/ui.js"; import { LOG_LEVEL as DEFAULT_FRONTEND_LOG_LEVEL } from "../../log_system/config.js"; import { logger as frontendLogger } from "../../log_system/logger.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; +import { createFloatingTreePicker } from "../utils/tree_picker.js"; export const downloadTargetMethods = { /** * Ensure all models are loaded for the dropdown. @@ -1900,17 +1901,6 @@ export const downloadTargetMethods = { })); }; - 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); @@ -1921,109 +1911,24 @@ export const downloadTargetMethods = { event.preventDefault(); }); } - let floatingPositionCleanup = null; - - const cleanupFloatingPositioning = () => { - if (floatingPositionCleanup) { - floatingPositionCleanup(); - floatingPositionCleanup = null; - } - }; - - const hideFloatingSubfolderList = () => { - cleanupFloatingPositioning(); - listEl.style.display = 'none'; - }; + const subfolderTreePicker = createFloatingTreePicker({ + listEl, + anchorEl: subfolderEl, + duplicateSelector: '.mr-download-target-list[data-ml-floating-portal="true"]', + floatingClass: 'mr-download-target-floating', + scrollSelector: '.mr-folder-browser-scroll', + minAvailableWidth: 260, + minPopupWidth: 420, + maxPopupWidth: 560, + openAboveThreshold: 260, + minAvailableHeight: 0, + minScrollHeight: 0, + setMinWidth: true + }); + const hideFloatingSubfolderList = () => subfolderTreePicker.hide(); + const showFloatingSubfolderList = () => subfolderTreePicker.show(); this.bindDropdownOutsideDismiss(listEl, [subfolderEl], hideFloatingSubfolderList); - 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(0, 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`; - listEl.style.maxHeight = `${availableHeight}px`; - - const scrollEl = listEl.querySelector('.mr-folder-browser-scroll'); - if (scrollEl) { - scrollEl.style.height = ''; - scrollEl.style.maxHeight = ''; - - const containerStyle = window.getComputedStyle(listEl); - const containerChromeHeight = [ - containerStyle.borderTopWidth, - containerStyle.borderBottomWidth, - containerStyle.paddingTop, - containerStyle.paddingBottom - ].reduce((height, value) => height + (Number.parseFloat(value) || 0), 0); - const chromeHeight = Array.from(listEl.children).reduce((height, child) => { - if (child === scrollEl) return height; - const style = window.getComputedStyle(child); - const marginTop = Number.parseFloat(style.marginTop) || 0; - const marginBottom = Number.parseFloat(style.marginBottom) || 0; - return height + child.offsetHeight + marginTop + marginBottom; - }, 0); - const scrollHeight = Math.max(0, availableHeight - chromeHeight - containerChromeHeight); - scrollEl.style.maxHeight = `${scrollHeight}px`; - } - - const popupHeight = listEl.offsetHeight; - 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)); - listEl.style.top = `${top}px`; - }; - - const bindFloatingPositioning = () => { - cleanupFloatingPositioning(); - const updatePosition = (event) => { - if (event?.type === 'scroll' && event.target instanceof Node && listEl.contains(event.target)) { - return; - } - 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, anchorEl = null) => { const options = values.map(value => ( typeof value === 'object' diff --git a/web/resolver/utils/tree_picker.js b/web/resolver/utils/tree_picker.js new file mode 100644 index 0000000..ee19658 --- /dev/null +++ b/web/resolver/utils/tree_picker.js @@ -0,0 +1,155 @@ +export function clampNumber(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +export function createFloatingTreePicker({ + listEl, + anchorEl, + duplicateSelector = '', + floatingClass = 'mr-download-target-floating', + browserClass = '', + scrollSelector = '.mr-folder-browser-scroll', + viewportPadding = 12, + gap = 6, + minViewportWidth = 320, + minViewportHeight = 240, + minAvailableWidth = 260, + minPopupWidth = 420, + maxPopupWidth = 560, + openAboveThreshold = 260, + minAvailableHeight = 0, + minScrollHeight = 0, + setMinWidth = false, + roundValues = false, + onHide = null +} = {}) { + const cleanup = () => { + if (typeof listEl?._mlFloatingPositionCleanup === 'function') { + listEl._mlFloatingPositionCleanup(); + listEl._mlFloatingPositionCleanup = null; + } + }; + + const hide = () => { + if (!listEl) return; + cleanup(); + listEl.style.display = 'none'; + if (typeof onHide === 'function') onHide(); + }; + + const portal = () => { + if (!listEl) return; + if (duplicateSelector) { + document.querySelectorAll(duplicateSelector).forEach(existing => { + if (existing !== listEl && (!listEl.id || existing.id === listEl.id)) { + existing.remove(); + } + }); + } + if (listEl.dataset.mlFloatingPortal !== 'true') { + listEl.dataset.mlFloatingPortal = 'true'; + document.body.appendChild(listEl); + } + if (floatingClass) listEl.classList.add(floatingClass); + if (browserClass) listEl.classList.add(browserClass); + }; + + const format = (value) => `${roundValues ? Math.round(value) : value}px`; + + const position = () => { + if (!listEl || !anchorEl || listEl.style.display === 'none') return; + + const rect = anchorEl.getBoundingClientRect(); + const viewportWidth = Math.max(minViewportWidth, window.innerWidth || document.documentElement.clientWidth || 0); + const viewportHeight = Math.max(minViewportHeight, window.innerHeight || document.documentElement.clientHeight || 0); + if (rect.bottom < viewportPadding || rect.top > viewportHeight - viewportPadding) { + hide(); + return; + } + + const availableWidth = Math.max(minAvailableWidth, viewportWidth - viewportPadding * 2); + const targetWidth = Math.min(maxPopupWidth, availableWidth, Math.max(rect.width, minPopupWidth)); + const left = clampNumber( + rect.left, + viewportPadding, + Math.max(viewportPadding, viewportWidth - targetWidth - viewportPadding) + ); + const spaceBelow = viewportHeight - rect.bottom - viewportPadding - gap; + const spaceAbove = rect.top - viewportPadding - gap; + const openAbove = spaceBelow < openAboveThreshold && spaceAbove > spaceBelow; + const availableHeight = Math.max(minAvailableHeight, openAbove ? spaceAbove : spaceBelow); + + listEl.style.position = 'fixed'; + listEl.style.left = format(left); + listEl.style.right = 'auto'; + listEl.style.width = format(targetWidth); + if (setMinWidth) { + listEl.style.minWidth = format(Math.min(rect.width, targetWidth)); + } + listEl.style.maxWidth = format(availableWidth); + listEl.style.maxHeight = format(availableHeight); + + const scrollEl = listEl.querySelector(scrollSelector); + if (scrollEl) { + scrollEl.style.height = ''; + scrollEl.style.maxHeight = ''; + const containerStyle = window.getComputedStyle(listEl); + const containerChromeHeight = [ + containerStyle.borderTopWidth, + containerStyle.borderBottomWidth, + containerStyle.paddingTop, + containerStyle.paddingBottom + ].reduce((height, value) => height + (Number.parseFloat(value) || 0), 0); + const chromeHeight = Array.from(listEl.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); + const scrollHeight = Math.max(minScrollHeight, availableHeight - chromeHeight - containerChromeHeight); + scrollEl.style.maxHeight = format(scrollHeight); + } + + const popupHeight = Math.min(listEl.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)); + listEl.style.top = format(top); + }; + + const bindPositioning = () => { + cleanup(); + const updatePosition = (event) => { + if (event?.type === 'scroll' && event.target instanceof Node && listEl?.contains(event.target)) { + return; + } + position(); + }; + window.addEventListener('resize', updatePosition, true); + window.addEventListener('scroll', updatePosition, true); + listEl._mlFloatingPositionCleanup = () => { + window.removeEventListener('resize', updatePosition, true); + window.removeEventListener('scroll', updatePosition, true); + }; + }; + + const show = () => { + if (!listEl) return; + portal(); + listEl.style.display = 'block'; + position(); + bindPositioning(); + requestAnimationFrame(position); + }; + + return { + cleanup, + hide, + portal, + position, + bindPositioning, + show + }; +} diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 839cac8..c75f8b5 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -2,6 +2,7 @@ import { api } from "../../../../../scripts/api.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; +import { createFloatingTreePicker } from "../utils/tree_picker.js"; export const missingBrowserMethods = { getMissingFilename(missing = {}) { return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; @@ -915,95 +916,24 @@ export const missingBrowserMethods = { ].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; - } - positionComboList(); - }; - window.addEventListener('resize', updatePosition, true); - window.addEventListener('scroll', updatePosition, true); - comboList._mlFloatingPositionCleanup = () => { - window.removeEventListener('resize', updatePosition, true); - window.removeEventListener('scroll', updatePosition, true); - }; - }; - const showComboList = () => { - if (!comboList) return; - 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; - cleanupComboPositioning(); - comboList.style.display = 'none'; - }; + const localModelTreePicker = createFloatingTreePicker({ + listEl: comboList, + anchorEl: comboInput, + duplicateSelector: '.mr-combo-list[data-ml-floating-portal="true"]', + floatingClass: 'mr-download-target-floating', + browserClass: 'mr-local-model-browser', + scrollSelector: '.mr-local-model-browser-scroll', + minAvailableWidth: 280, + minPopupWidth: 520, + maxPopupWidth: 760, + openAboveThreshold: 280, + minAvailableHeight: 180, + minScrollHeight: 120, + roundValues: true + }); + const showComboList = () => localModelTreePicker.show(); + const hideComboList = () => localModelTreePicker.hide(); + const positionComboList = () => localModelTreePicker.position(); if (comboList) { this.bindDropdownOutsideDismiss?.(comboList, [comboInput, comboRefresh], hideComboList); this.enableWheelScrollChaining(comboList);