Files
cloudron-box/dashboard/src/3rdparty/js/contextMenu.js
2023-02-24 23:40:15 +01:00

640 lines
24 KiB
JavaScript

(function($, angular) {
// eslint-disable-next-line angular/file-name, angular/no-service-method
angular.module('ui.bootstrap.contextMenu', [])
.service('CustomService', function () {
'use strict';
return {
initialize: function (item) {
console.log('got here', item);
}
};
})
.constant('ContextMenuEvents', {
// Triggers when all the context menus have been closed
ContextMenuAllClosed: 'context-menu-all-closed',
// Triggers when any single conext menu is called.
// Closing all context menus triggers this for each level open
ContextMenuClosed: 'context-menu-closed',
// Triggers right before the very first context menu is opened
ContextMenuOpening: 'context-menu-opening',
// Triggers right after any context menu is opened
ContextMenuOpened: 'context-menu-opened'
})
.directive('contextMenu', ['$rootScope', 'ContextMenuEvents', '$parse', '$q', 'CustomService', '$sce', '$document', '$window', '$compile',
function ($rootScope, ContextMenuEvents, $parse, $q, custom, $sce, $document, $window, $compile) {
var _contextMenus = [];
// Contains the element that was clicked to show the context menu
var _clickedElement = null;
var DEFAULT_ITEM_TEXT = '"New Item';
var _emptyText = 'empty';
function createAndAddOptionText(params) {
// Destructuring:
var $scope = params.$scope;
var item = params.item;
var event = params.event;
var modelValue = params.modelValue;
var $promises = params.$promises;
var nestedMenu = params.nestedMenu;
var $li = params.$li;
var leftOriented = String(params.orientation).toLowerCase() === 'left';
var optionText = null;
if (item.html) {
if (angular.isFunction(item.html)) {
// runs the function that expects a jQuery/jqLite element
optionText = item.html($scope);
} else {
// Incase we want to compile html string to initialize their custom directive in html string
if (item.compile) {
optionText = $compile(item.html)($scope);
} else {
// Assumes that the developer already placed a valid jQuery/jqLite element
optionText = item.html;
}
}
} else {
var $a = $('<a>');
var $anchorStyle = {};
if (leftOriented) {
$anchorStyle.textAlign = 'right';
$anchorStyle.paddingLeft = '8px';
} else {
$anchorStyle.textAlign = 'left';
$anchorStyle.paddingRight = '8px';
}
$a.css($anchorStyle);
$a.addClass('dropdown-item');
$a.attr({ tabindex: '-1', href: '#' });
var textParam = item.text || item[0];
var text = DEFAULT_ITEM_TEXT;
if (typeof textParam === 'string') {
text = textParam;
} else if (typeof textParam === 'function') {
text = textParam.call($scope, $scope, event, modelValue);
}
var $promise = $q.when(text);
$promises.push($promise);
$promise.then(function (pText) {
if (nestedMenu) {
var $arrow;
var $boldStyle = {
fontFamily: 'monospace',
fontWeight: 'bold'
};
if (leftOriented) {
$arrow = '&lt;';
$boldStyle.float = 'left';
} else {
$arrow = '&gt;';
$boldStyle.float = 'right';
}
var $bold = $('<strong style="font-family:monospace;font-weight:bold;float:right;">' + $arrow + '</strong>');
$bold.css($boldStyle);
$a.css('cursor', 'default');
$a.append($bold);
}
$a.append(pText);
});
optionText = $a;
}
$li.append(optionText);
return optionText;
};
/**
* Process each individual item
*
* Properties of params:
* - $scope
* - event
* - modelValue
* - level
* - item
* - $ul
* - $li
* - $promises
*/
function processItem(params) {
var nestedMenu = extractNestedMenu(params);
// if html property is not defined, fallback to text, otherwise use default text
// if first item in the item array is a function then invoke .call()
// if first item is a string, then text should be the string.
var text = DEFAULT_ITEM_TEXT;
var currItemParam = angular.extend({}, params);
var item = params.item;
var enabled = item.enabled === undefined ? item[2] : item.enabled;
currItemParam.nestedMenu = nestedMenu;
currItemParam.enabled = resolveBoolOrFunc(enabled, params);
currItemParam.text = createAndAddOptionText(currItemParam);
registerCurrentItemEvents(currItemParam);
};
/*
* Registers the appropriate mouse events for options if the item is enabled.
* Otherwise, it ensures that clicks to the item do not propagate.
*/
function registerCurrentItemEvents (params) {
// Destructuring:
var item = params.item;
var $ul = params.$ul;
var $li = params.$li;
var $scope = params.$scope;
var modelValue = params.modelValue;
var level = params.level;
var event = params.event;
var text = params.text;
var nestedMenu = params.nestedMenu;
var enabled = params.enabled;
var orientation = String(params.orientation).toLowerCase();
var customClass = params.customClass;
if (enabled) {
var openNestedMenu = function ($event) {
removeContextMenus(level + 1);
/*
* The object here needs to be constructed and filled with data
* on an "as needed" basis. Copying the data from event directly
* or cloning the event results in unpredictable behavior.
*/
/// adding the original event in the object to use the attributes of the mouse over event in the promises
var ev = {
pageX: orientation === 'left' ? event.pageX - $ul[0].offsetWidth + 1 : event.pageX + $ul[0].offsetWidth - 1,
pageY: $ul[0].offsetTop + $li[0].offsetTop - 3,
// eslint-disable-next-line angular/window-service
view: event.view || window,
target: event.target,
event: $event
};
/*
* At this point, nestedMenu can only either be an Array or a promise.
* Regardless, passing them to `when` makes the implementation singular.
*/
$q.when(nestedMenu).then(function(promisedNestedMenu) {
if (angular.isFunction(promisedNestedMenu)) {
// support for dynamic subitems
promisedNestedMenu = promisedNestedMenu.call($scope, $scope, event, modelValue, text, $li);
}
var nestedParam = {
$scope : $scope,
event : ev,
options : promisedNestedMenu,
modelValue : modelValue,
level : level + 1,
orientation: orientation,
customClass: customClass
};
renderContextMenu(nestedParam);
});
};
$li.on('click', function ($event) {
if($event.which == 1) {
$event.preventDefault();
$scope.$apply(function () {
var cleanupFunction = function () {
$(event.currentTarget).removeClass('context');
removeAllContextMenus();
};
var clickFunction = angular.isFunction(item.click)
? item.click
: (angular.isFunction(item[1])
? item[1]
: null);
if (clickFunction) {
var res = clickFunction.call($scope, $scope, event, modelValue, text, $li);
if(res === undefined || res) {
cleanupFunction();
}
} else {
cleanupFunction();
}
});
}
});
$li.on('mouseover', function ($event) {
$scope.$apply(function () {
if (nestedMenu) {
openNestedMenu($event);
} else {
removeContextMenus(level + 1);
}
});
});
} else {
setElementDisabled($li);
}
};
/**
* @param params - an object containing the `item` parameter
* @returns an Array or a Promise containing the children,
* or null if the option has no submenu
*/
function extractNestedMenu(params) {
// Destructuring:
var item = params.item;
// New implementation:
if (item.children) {
if (angular.isFunction(item.children)) {
// Expects a function that returns a Promise or an Array
return item.children();
} else if (angular.isFunction(item.children.then) || angular.isArray(item.children)) {
// Returns the promise
// OR, returns the actual array
return item.children;
}
return null;
} else {
// nestedMenu is either an Array or a Promise that will return that array.
// NOTE: This might be changed soon as it's a hangover from an old implementation
return angular.isArray(item[1]) ||
(item[1] && angular.isFunction(item[1].then)) ? item[1] : angular.isArray(item[2]) ||
(item[2] && angular.isFunction(item[2].then)) ? item[2] : angular.isArray(item[3]) ||
(item[3] && angular.isFunction(item[3].then)) ? item[3] : null;
}
}
/**
* Responsible for the actual rendering of the context menu.
*
* The parameters in params are:
* - $scope = the scope of this context menu
* - event = the event that triggered this context menu
* - options = the options for this context menu
* - modelValue = the value of the model attached to this context menu
* - level = the current context menu level (defauts to 0)
* - customClass = the custom class to be used for the context menu
*/
function renderContextMenu (params) {
/// <summary>Render context menu recursively.</summary>
// Destructuring:
var $scope = params.$scope;
var event = params.event;
var options = params.options;
var modelValue = params.modelValue;
var level = params.level;
var customClass = params.customClass;
// Initialize the container. This will be passed around
var $ul = initContextMenuContainer(params);
params.$ul = $ul;
// Register this level of the context menu
_contextMenus.push($ul);
/*
* This object will contain any promises that we have
* to wait for before trying to adjust the context menu.
*/
var $promises = [];
params.$promises = $promises;
angular.forEach(options, function (item) {
if (item === null) {
appendDivider($ul);
} else {
// If displayed is anything other than a function or a boolean
var displayed = resolveBoolOrFunc(item.displayed, params);
// Only add the <li> if the item is displayed
if (displayed) {
var $li = $('<li>');
var itemParams = angular.extend({}, params);
itemParams.item = item;
itemParams.$li = $li;
if (typeof item[0] === 'object') {
custom.initialize($li, item);
} else {
processItem(itemParams);
}
if (resolveBoolOrFunc(item.hasTopDivider, itemParams, false)) {
appendDivider($ul);
}
$ul.append($li);
if (resolveBoolOrFunc(item.hasBottomDivider, itemParams, false)) {
appendDivider($ul);
}
}
}
});
if ($ul.children().length === 0) {
var $emptyLi = angular.element('<li>');
setElementDisabled($emptyLi);
$emptyLi.html('<a>' + _emptyText + '</a>');
$ul.append($emptyLi);
}
$document.find('body').append($ul);
doAfterAllPromises(params);
$rootScope.$broadcast(ContextMenuEvents.ContextMenuOpened, {
context: _clickedElement,
contextMenu: $ul,
params: params
});
};
/**
* calculate if drop down menu would go out of screen at left or bottom
* calculation need to be done after element has been added (and all texts are set; thus the promises)
* to the DOM the get the actual height
*/
function doAfterAllPromises (params) {
// Desctructuring:
var $ul = params.$ul;
var $promises = params.$promises;
var level = params.level;
var event = params.event;
var leftOriented = String(params.orientation).toLowerCase() === 'left';
$q.all($promises).then(function () {
var topCoordinate = event.pageY;
var menuHeight = angular.element($ul[0]).prop('offsetHeight');
var winHeight = $window.pageYOffset + event.view.innerHeight;
/// the 20 pixels in second condition are considering the browser status bar that sometimes overrides the element
if (topCoordinate > menuHeight && winHeight - topCoordinate < menuHeight + 20) {
topCoordinate = event.pageY - menuHeight;
/// If the element is a nested menu, adds the height of the parent li to the topCoordinate to align with the parent
if(level && level > 0) {
topCoordinate += event.event.currentTarget.offsetHeight;
}
} else if(winHeight <= menuHeight) {
// If it really can't fit, reset the height of the menu to one that will fit
angular.element($ul[0]).css({ 'height': winHeight - 5, 'overflow-y': 'scroll' });
// ...then set the topCoordinate height to 0 so the menu starts from the top
topCoordinate = 0;
} else if(winHeight - topCoordinate < menuHeight) {
var reduceThresholdY = 5;
if(topCoordinate < reduceThresholdY) {
reduceThresholdY = topCoordinate;
}
topCoordinate = winHeight - menuHeight - reduceThresholdY;
}
var leftCoordinate = event.pageX;
var menuWidth = angular.element($ul[0]).prop('offsetWidth');
var winWidth = event.view.innerWidth + window.pageXOffset;
var padding = 5;
if (leftOriented) {
if (winWidth - leftCoordinate > menuWidth && leftCoordinate < menuWidth + padding) {
leftCoordinate = padding;
} else if (leftCoordinate < menuWidth) {
var reduceThresholdX = 5;
if (winWidth - leftCoordinate < reduceThresholdX + padding) {
reduceThresholdX = winWidth - leftCoordinate + padding;
}
leftCoordinate = menuWidth + reduceThresholdX + padding;
} else {
leftCoordinate = leftCoordinate - menuWidth;
}
} else {
if (leftCoordinate > menuWidth && winWidth - leftCoordinate - padding < menuWidth) {
leftCoordinate = winWidth - menuWidth - padding;
} else if(winWidth - leftCoordinate < menuWidth) {
var reduceThresholdX = 5;
if(leftCoordinate < reduceThresholdX + padding) {
reduceThresholdX = leftCoordinate + padding;
}
leftCoordinate = winWidth - menuWidth - reduceThresholdX - padding;
}
}
$ul.css({
display: 'block',
position: 'absolute',
left: leftCoordinate + 'px',
top: topCoordinate + 'px'
});
});
};
/**
* Creates the container of the context menu (a <ul> element),
* applies the appropriate styles and then returns that container
*
* @return a <ul> jqLite/jQuery element
*/
function initContextMenuContainer(params) {
// Destructuring
var customClass = params.customClass;
var $ul = $('<ul>');
$ul.addClass('dropdown-menu');
$ul.attr({ 'role': 'menu' });
$ul.css({
display: 'block',
position: 'absolute',
left: params.event.pageX + 'px',
top: params.event.pageY + 'px',
'z-index': 10000
});
if(customClass) { $ul.addClass(customClass); }
return $ul;
}
function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints; // works on most browsers | works on IE10/11 and Surface
}
/**
* Removes the context menus with level greater than or equal
* to the value passed. If undefined, null or 0, all context menus
* are removed.
*/
function removeContextMenus (level) {
while (_contextMenus.length && (!level || _contextMenus.length > level)) {
var cm = _contextMenus.pop();
$rootScope.$broadcast(ContextMenuEvents.ContextMenuClosed, { context: _clickedElement, contextMenu: cm });
cm.remove();
}
if(!level) {
$rootScope.$broadcast(ContextMenuEvents.ContextMenuAllClosed, { context: _clickedElement });
}
}
function removeOnScrollEvent(e) {
removeAllContextMenus(e);
}
function removeOnOutsideClickEvent(e) {
var $curr = $(e.target);
var shouldRemove = true;
while($curr.length) {
if ($curr.hasClass('dropdown-menu')) {
shouldRemove = false;
break;
} else {
$curr = $curr.parent();
}
}
if (shouldRemove) {
removeAllContextMenus(e);
}
}
function removeAllContextMenus(e) {
$document.find('body').off('mousedown touchstart', removeOnOutsideClickEvent);
$document.off('scroll', removeOnScrollEvent);
$(_clickedElement).removeClass('context');
removeContextMenus();
$rootScope.$broadcast('');
}
function isBoolean(a) {
return a === false || a === true;
}
/** Resolves a boolean or a function that returns a boolean
* Returns true by default if the param is null or undefined
* @param a - the parameter to be checked
* @param params - the object for the item's parameters
* @param defaultValue - the default boolean value to use if the parameter is
* neither a boolean nor function. True by default.
*/
function resolveBoolOrFunc(a, params, defaultValue) {
var item = params.item;
var $scope = params.$scope;
var event = params.event;
var modelValue = params.modelValue;
defaultValue = isBoolean(defaultValue) ? defaultValue : true;
if (isBoolean(a)) {
return a;
} else if (angular.isFunction(a)) {
return a.call($scope, $scope, event, modelValue);
} else {
return defaultValue;
}
}
function appendDivider($ul) {
var $li = angular.element('<li>');
$li.addClass('divider');
$ul.append($li);
}
function setElementDisabled($li) {
$li.on('click', function ($event) {
$event.preventDefault();
});
$li.addClass('disabled');
}
return function ($scope, element, attrs) {
var openMenuEvents = ['contextmenu'];
_emptyText = $scope.$eval(attrs.contextMenuEmptyText) || 'empty';
if(attrs.contextMenuOn && typeof(attrs.contextMenuOn) === 'string'){
openMenuEvents = attrs.contextMenuOn.split(',');
}
angular.forEach(openMenuEvents, function (openMenuEvent) {
element.on(openMenuEvent.trim(), function (event) {
// Cleanup any leftover contextmenus(there are cases with longpress on touch where we
// still see multiple contextmenus)
removeAllContextMenus();
if(!attrs.allowEventPropagation) {
event.stopPropagation();
event.preventDefault();
}
// Don't show context menu if on touch device and element is draggable
if(isTouchDevice() && element.attr('draggable') === 'true') {
return false;
}
// Remove if the user clicks outside
$document.find('body').on('mousedown touchstart', removeOnOutsideClickEvent);
// Remove the menu when the scroll moves
$document.on('scroll', removeOnScrollEvent);
_clickedElement = event.currentTarget;
$(_clickedElement).addClass('context');
$scope.$apply(function () {
var options = $scope.$eval(attrs.contextMenu);
var customClass = attrs.contextMenuClass;
var modelValue = $scope.$eval(attrs.model);
var orientation = attrs.contextMenuOrientation;
$q.when(options).then(function(promisedMenu) {
if (angular.isFunction(promisedMenu)) {
// support for dynamic items
promisedMenu = promisedMenu.call($scope, $scope, event, modelValue);
}
var params = {
'$scope' : $scope,
'event' : event,
'options' : promisedMenu,
'modelValue' : modelValue,
'level' : 0,
'customClass' : customClass,
'orientation': orientation
};
$rootScope.$broadcast(ContextMenuEvents.ContextMenuOpening, { context: _clickedElement });
renderContextMenu(params);
});
});
// Remove all context menus if the scope is destroyed
$scope.$on('$destroy', function () {
removeAllContextMenus();
});
});
});
if (attrs.closeMenuOn) {
$scope.$on(attrs.closeMenuOn, function () {
removeAllContextMenus();
});
}
};
}]);
// eslint-disable-next-line angular/window-service
})(window.angular.element, window.angular);