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.
156 lines
5.9 KiB
JavaScript
156 lines
5.9 KiB
JavaScript
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
|
|
};
|
|
}
|