refactor: extract floating tree picker positioning into shared tree_picker.js
Create createFloatingTreePicker() utility to replace duplicated inline floating positioning logic in download_target_methods.js and missing_browser_methods.js. The factory handles portalization, duplicate removal, viewport-aware positioning (open above/below), scroll container height calculation, and scroll/resize listener lifecycle management, reducing ~175 lines of duplicated code.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user