(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 = $(''); 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 = '<'; $boldStyle.float = 'left'; } else { $arrow = '>'; $boldStyle.float = 'right'; } var $bold = $('' + $arrow + ''); $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) { /// Render context menu recursively. // 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
  • if the item is displayed if (displayed) { var $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('
  • '); setElementDisabled($emptyLi); $emptyLi.html('' + _emptyText + ''); $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