Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 209 additions & 2 deletions packages/node_modules/@node-red/editor-client/src/js/ui/common/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,75 @@
**/
RED.menu = (function() {

var currentPortaledSubmenu = null;
var menuItems = {};
let menuItemCount = 0

/**
* Position a submenu relative to its parent item, handling viewport collisions.
* @param {HTMLElement} parentMenuEl - The parent menu item
* @param {HTMLElement} submenuEl - The submenu to position
* @param {string} preferredSide - 'left' or 'right'
* @returns {{x: number, y: number, placement: string}}
*/
function getSubmenuPosition(parentMenuEl, submenuEl, preferredSide) {
var parentMenuRect = parentMenuEl.getBoundingClientRect();
var submenuWidth = submenuEl.offsetWidth || 230;
var submenuHeight = submenuEl.offsetHeight || 200;
var viewportWidth = window.innerWidth;
var viewportHeight = window.innerHeight;
var padding = 10;

// Initial position variables
var x;
var y = parentMenuRect.top;

// Shift y if it would overflow bottom of viewport
if (y + submenuHeight > viewportHeight - padding) {
y = viewportHeight - submenuHeight - padding;
}

// Shift y if it would overflow top of viewport
if (y < padding) {
y = padding;
}

// Calculate x position based on preferred side
if (preferredSide === 'left') {
x = parentMenuRect.left - submenuWidth;
// Flip to right if not enough space on left
if (x < padding) {
x = parentMenuRect.right;
// If still not enough space, shift to fit
if (x + submenuWidth > viewportWidth - padding) {
x = viewportWidth - submenuWidth - padding;
}
}
} else {
x = parentMenuRect.right;
// Flip to left if not enough space on right
if (x + submenuWidth > viewportWidth - padding) {
x = parentMenuRect.left - submenuWidth;
// If still not enough space, shift to fit
if (x < padding) {
x = padding;
}
}
}

return { x: x, y: y };
}

/**
* Clean up the currently portaled submenu (return it to original position)
*/
function cleanupPortaledSubmenus() {
if (currentPortaledSubmenu && currentPortaledSubmenu.isPortaled) {
currentPortaledSubmenu.cleanUpSubmenu();
}
currentPortaledSubmenu = null;
}

function createMenuItem(opt) {
var item;

Expand Down Expand Up @@ -132,7 +198,8 @@ RED.menu = (function() {
}
if (opt.options) {
item.addClass("red-ui-menu-dropdown-submenu"+(opt.direction!=='right'?" pull-left":""));
var submenu = $('<ul id="'+opt.id+'-submenu" class="red-ui-menu-dropdown"></ul>').appendTo(item);
var submenuClasses = "red-ui-menu-dropdown" + (opt.isHeaderMenu ? " red-ui-header-menu" : "");
var submenu = $('<ul id="'+opt.id+'-submenu" class="'+submenuClasses+'"></ul>').appendTo(item);
var hasIcons = false
var hasSubmenus = false

Expand All @@ -146,6 +213,7 @@ RED.menu = (function() {
opt.options[i].onpostselect = opt.onpostselect
}
opt.options[i].direction = opt.direction
opt.options[i].isHeaderMenu = opt.isHeaderMenu
hasIcons = hasIcons || (opt.options[i].icon);
hasSubmenus = hasSubmenus || (opt.options[i].options);
}
Expand All @@ -162,6 +230,116 @@ RED.menu = (function() {
submenu.addClass("red-ui-menu-dropdown-submenus")
}

// Setup submenu portaling for scrollable parent menus
(function(item, submenu, direction) {
var isPortaled = false;
var originalParentItem = item;

function doesSubmenuNeedPortaling() {
var parentMenu = item.closest(".red-ui-menu-dropdown");
var overflowY = parentMenu.css("overflow-y");
// If scroll, portal it
if (overflowY === "auto" || overflowY === "scroll") {
return true;
}

var itemRect = item[0].getBoundingClientRect();
var submenuWidth = submenu.outerWidth() || 230;
var viewportWidth = window.innerWidth;
var padding = 10;

// If right overflow, portal it
if (itemRect.right + submenuWidth > viewportWidth - padding) {
return true;
}
return false;
}

function portalSubmenu() {
if (!doesSubmenuNeedPortaling()) {
return;
}
if (isPortaled) {
updatePositionOfSubmenu();
return;
}

isPortaled = true;
submenu.appendTo('body');
submenu.addClass('red-ui-menu-dropdown-portaled');
updatePositionOfSubmenu();
}

function updatePositionOfSubmenu() {
if (!isPortaled) {
return;
}
var {x, y} = getSubmenuPosition(item[0], submenu[0], direction);
submenu.css({
'top': y + 'px',
'left': x + 'px'
});
}

function cleanUpSubmenu() {
if (!isPortaled) {
return;
}
isPortaled = false;
submenu.removeClass('red-ui-menu-dropdown-portaled');
submenu.appendTo(originalParentItem);

if (currentPortaledSubmenu && currentPortaledSubmenu.submenu === submenu) {
currentPortaledSubmenu = null;
}
}

var submenuInfo = {
submenu,
cleanUpSubmenu
};

Object.defineProperty(submenuInfo, 'isPortaled', {
get: function() { return isPortaled; }
});

item.on("mouseenter", function() {
if (currentPortaledSubmenu && currentPortaledSubmenu.isPortaled && currentPortaledSubmenu.submenu !== submenu) {
currentPortaledSubmenu.cleanUpSubmenu();
}

portalSubmenu();

if (isPortaled) {
currentPortaledSubmenu = submenuInfo;
}
});

item.on("mouseleave", function(e) {
if (!isPortaled) {
return;
}
// Check if mouse moved to the submenu
var related = e.relatedTarget;
if (related && (submenu[0].contains(related) || submenu[0] === related)) {
return;
}
cleanUpSubmenu();
});

submenu.on("mouseleave", function(e) {
if (!isPortaled) {
return;
}
// Check if mouse moved back to parent item
var related = e.relatedTarget;
if (related && (item[0].contains(related) || item[0] === related)) {
return;
}
cleanUpSubmenu();
});

})(item, submenu, opt.direction);

}
if (opt.disabled) {
Expand All @@ -177,10 +355,22 @@ RED.menu = (function() {

}
function createMenu(options) {
var topMenu = $("<ul/>",{class:"red-ui-menu red-ui-menu-dropdown pull-right"});
// Check if menu is in header - we need to add a styling class since portaling to the body loses css context
var isHeaderMenu = false;
if (options.id) {
var menuParent = $("#"+options.id);
if (menuParent.length === 1 && menuParent.closest('#red-ui-header').length > 0) {
isHeaderMenu = true;
}
}

var menuClasses = "red-ui-menu red-ui-menu-dropdown pull-right" + (isHeaderMenu ? " red-ui-header-menu" : "");
var topMenu = $("<ul/>",{class: menuClasses});
if (options.direction) {
topMenu.addClass("red-ui-menu-dropdown-direction-"+options.direction)
}
options.isHeaderMenu = isHeaderMenu;

if (options.id) {
topMenu.attr({id:options.id+"-submenu"});
var menuParent = $("#"+options.id);
Expand All @@ -191,15 +381,31 @@ RED.menu = (function() {
evt.preventDefault();
if (topMenu.is(":visible")) {
$(document).off("click.red-ui-menu");
cleanupPortaledSubmenus();
topMenu.hide();
topMenu.css({ maxHeight: "", overflowY: "" });
} else {
$(document).on("click.red-ui-menu", function(evt) {
$(document).off("click.red-ui-menu");
activeMenu = null;
cleanupPortaledSubmenus();
topMenu.hide();
topMenu.css({ maxHeight: "", overflowY: "" });
});
$(".red-ui-menu.red-ui-menu-dropdown").hide();
topMenu.show();

// Enable scrolling if menu exceeds viewport
var menuOffset = topMenu.offset();
var menuHeight = topMenu.outerHeight();
var windowHeight = $(window).height();
var spaceBelow = windowHeight - menuOffset.top;
if (menuHeight > spaceBelow - 10) {
topMenu.css({
maxHeight: (spaceBelow - 10) + "px",
overflowY: "auto"
});
}
}
})
}
Expand All @@ -218,6 +424,7 @@ RED.menu = (function() {
opt.onpostselect = options.onpostselect
}
opt.direction = options.direction || 'left'
opt.isHeaderMenu = options.isHeaderMenu
}
if (opt !== null || !lastAddedSeparator) {
hasIcons = hasIcons || (opt && opt.icon);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ RED.tabs = (function() {
})
menu.appendTo("body");
var elementPos = menuButton.offset();
var top = elementPos.top + menuButton.height() - 2;
menu.css({
top: (elementPos.top+menuButton.height()-2)+"px",
top: top + "px",
left: (elementPos.left - menu.width() + menuButton.width())+"px"
})
$(".red-ui-menu.red-ui-menu-dropdown").hide();
Expand All @@ -138,6 +139,17 @@ RED.tabs = (function() {
menu.remove();
});
menu.show();

// Enable scrolling if menu exceeds viewport
var menuHeight = menu.outerHeight();
var windowHeight = $(window).height();
var spaceBelow = windowHeight - top;
if (menuHeight > spaceBelow - 10) {
menu.css({
maxHeight: (spaceBelow - 10) + "px",
overflowY: "auto"
});
}
})
}

Expand Down Expand Up @@ -233,20 +245,36 @@ RED.tabs = (function() {
collapsibleMenu.appendTo("body");
}
var elementPos = selectButton.offset();
var top = elementPos.top + selectButton.height() - 2;
collapsibleMenu.css({
top: (elementPos.top+selectButton.height()-2)+"px",
top: top + "px",
left: (elementPos.left - collapsibleMenu.width() + selectButton.width())+"px"
})
if (collapsibleMenu.is(":visible")) {
$(document).off("click.red-ui-tabmenu");
collapsibleMenu.css({ maxHeight: "", overflowY: "" });
} else {
$(".red-ui-menu.red-ui-menu-dropdown").hide();
$(document).on("click.red-ui-tabmenu", function(evt) {
$(document).off("click.red-ui-tabmenu");
collapsibleMenu.hide();
collapsibleMenu.css({ maxHeight: "", overflowY: "" });
});
}
collapsibleMenu.toggle();

// Enable scrolling if menu exceeds viewport
if (collapsibleMenu.is(":visible")) {
var menuHeight = collapsibleMenu.outerHeight();
var windowHeight = $(window).height();
var spaceBelow = windowHeight - top;
if (menuHeight > spaceBelow - 10) {
collapsibleMenu.css({
maxHeight: (spaceBelow - 10) + "px",
overflowY: "auto"
});
}
}
})
}

Expand Down
Loading