import ContextMenuHelper from './ContextMenuHelper';
import ContextMenuItemTypes from './ContextMenuItemTypes';
export default class ContextMenuOperations {
/**
* @constructor
* @constructs ContextMenuOperations
*/
constructor() {
return this;
}
/**
* Show the menu.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} menuData
* @param {number} x
* @param {number} y
*/
show(e, menuData, x, y) {
const $trigger = $(this);
const css = {};
// hide any open menus
$('#context-menu-layer').trigger('mousedown');
// backreference for callbacks
menuData.$trigger = $trigger;
// show event
if (menuData.events.show.call($trigger, e, menuData) === false) {
menuData.manager.handler.$currentTrigger = null;
return;
}
// create or update context menu
menuData.manager.operations.update.call($trigger, e, menuData);
// position menu
menuData.position.call($trigger, e, menuData, x, y);
// make sure we're in front
if (menuData.zIndex) {
let additionalZValue = menuData.zIndex;
// If menuData.zIndex is a function, call the function to get the right zIndex.
if (typeof menuData.zIndex === 'function') {
additionalZValue = menuData.zIndex.call($trigger, menuData);
}
css.zIndex = ContextMenuHelper.zindex($trigger) + additionalZValue;
}
// add layer
menuData.manager.operations.layer.call(menuData.$menu, e, menuData, css.zIndex);
// adjust sub-menu zIndexes
menuData.$menu.find('ul').css('zIndex', css.zIndex + 1);
// position and show context menu
menuData.$menu.css(css)[menuData.animation.show](menuData.animation.duration, () => {
$trigger.trigger('contextmenu:visible');
menuData.manager.operations.activated(e, menuData);
menuData.events.activated($trigger, e, menuData);
});
// make options available and set state
$trigger
.data('contextMenu', menuData)
.addClass('context-menu-active');
// register key handler
$(document).off('keydown.contextMenu').on('keydown.contextMenu', menuData, menuData.manager.handler.key);
// register autoHide handler
if (menuData.autoHide) {
// mouse position handler
$(document).on('mousemove.contextMenuAutoHide', function (e) {
// need to capture the offset on mousemove,
// since the page might've been scrolled since activation
const pos = $trigger.offset();
pos.right = pos.left + $trigger.outerWidth();
pos.bottom = pos.top + $trigger.outerHeight();
if (menuData.$layer && !menuData.hovering && (!(e.pageX >= pos.left && e.pageX <= pos.right) || !(e.pageY >= pos.top && e.pageY <= pos.bottom))) {
/* Additional hover check after short time, you might just miss the edge of the menu */
setTimeout(function () {
if (!menuData.hovering && menuData.$menu !== null && typeof menuData.$menu !== 'undefined') {
menuData.$menu.trigger('contextmenu:hide');
}
}, 50);
}
});
}
}
/**
* Hide the menu.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} menuData
* @param {boolean} force
*/
hide(e, menuData, force) {
const $trigger = $(this);
if (typeof menuData !== 'object' && $trigger.data('contextMenu')) {
menuData = $trigger.data('contextMenu');
} else if (typeof menuData !== 'object') {
return;
}
// hide event
if (!force && menuData.events && menuData.events.hide.call($trigger, e, menuData) === false) {
return;
}
// remove options and revert state
$trigger
.removeData('contextMenu')
.removeClass('context-menu-active');
if (menuData.$layer) {
// keep layer for a bit so the contextmenu event can be aborted properly by opera
setTimeout((function ($layer) {
return function () {
$layer.remove();
};
})(menuData.$layer), 10);
try {
delete menuData.$layer;
} catch (e) {
menuData.$layer = null;
}
}
// remove handle
menuData.manager.handler.$currentTrigger = null;
// remove selected
menuData.$menu.find('.' + menuData.classNames.hover).trigger('contextmenu:blur');
menuData.$selected = null;
// collapse all submenus
menuData.$menu.find('.' + menuData.classNames.visible).removeClass(menuData.classNames.visible);
// unregister key and mouse handlers
$(document).off('.contextMenuAutoHide').off('keydown.contextMenu');
// hide menu
if (menuData.$menu) {
menuData.$menu[menuData.animation.hide](menuData.animation.duration, function () {
// tear down dynamically built menu after animation is completed.
if (menuData.build) {
menuData.$menu.remove();
Object.keys(menuData).forEach((key) => {
switch (key) {
case 'ns':
case 'selector':
case 'build':
case 'trigger':
return true;
default:
menuData[key] = undefined;
try {
delete menuData[key];
} catch (e) {
}
return true;
}
});
}
setTimeout(function () {
$trigger.trigger('contextmenu:hidden');
}, 10);
});
}
}
/**
* Create a menu based on the options. Also created submenus.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} currentMenuData
* @param {ContextMenuData?} rootMenuData
*/
create(e, currentMenuData, rootMenuData) {
if (typeof rootMenuData === 'undefined') {
rootMenuData = currentMenuData;
}
// create contextMenu
currentMenuData.$menu = $('<ul class="context-menu-list"></ul>').addClass(currentMenuData.className || '').data({
'contextMenu': currentMenuData,
'contextMenuRoot': rootMenuData
});
$.each(['callbacks', 'commands', 'inputs'], function (i, k) {
currentMenuData[k] = {};
if (!rootMenuData[k]) {
rootMenuData[k] = {};
}
});
if (!rootMenuData.accesskeys) {
rootMenuData.accesskeys = {};
}
function createNameNode(item) {
const $name = $('<span></span>');
if (item._accesskey) {
if (item._beforeAccesskey) {
$name.append(document.createTextNode(item._beforeAccesskey));
}
$('<span></span>')
.addClass('context-menu-accesskey')
.text(item._accesskey)
.appendTo($name);
if (item._afterAccesskey) {
$name.append(document.createTextNode(item._afterAccesskey));
}
} else {
if (item.isHtmlName) {
// restrict use with access keys
if (typeof item.accesskey !== 'undefined') {
throw new Error('accesskeys are not compatible with HTML names and cannot be used together in the same item');
}
$name.html(item.name);
} else {
$name.text(item.name);
}
}
return $name;
}
// create contextMenu items
$.each(currentMenuData.items, function (key, item) {
let $t = $('<li class="context-menu-item"></li>').addClass(item.className || '');
let $label = null;
let $input = null;
// iOS needs to see a click-event bound to an element to actually
// have the TouchEvents infrastructure trigger the click event
$t.on('click', $.noop);
// Make old school string separator a real item so checks wont be
// akward later.
// And normalize 'cm_separator' into 'cm_separator'.
if (typeof item === 'string' || item.type === 'cm_seperator') {
item = {type: ContextMenuItemTypes.separator};
}
item.$node = $t.data({
'contextMenu': currentMenuData,
'contextMenuRoot': rootMenuData,
'contextMenuKey': key
});
// register accesskey
// NOTE: the accesskey attribute should be applicable to any element, but Safari5 and Chrome13 still can't do that
if (typeof item.accesskey !== 'undefined') {
const aks = ContextMenuHelper.splitAccesskey(item.accesskey);
for (let i = 0, ak; ak = aks[i]; i++) {
if (!rootMenuData.accesskeys[ak]) {
rootMenuData.accesskeys[ak] = item;
const matched = item.name.match(new RegExp('^(.*?)(' + ak + ')(.*)$', 'i'));
if (matched) {
item._beforeAccesskey = matched[1];
item._accesskey = matched[2];
item._afterAccesskey = matched[3];
}
break;
}
}
}
if (item.type && rootMenuData.types[item.type]) {
// run custom type handler
rootMenuData.types[item.type].call($t, e, item, currentMenuData, rootMenuData);
// register commands
$.each([currentMenuData, rootMenuData], function (i, k) {
k.commands[key] = item;
// Overwrite only if undefined or the item is appended to the rootMenuData. This so it
// doesn't overwrite callbacks of rootMenuData elements if the name is the same.
if ($.isFunction(item.callback) && (typeof k.callbacks[key] === 'undefined' || typeof currentMenuData.type === 'undefined')) {
k.callbacks[key] = item.callback;
}
});
} else {
// add label for input
if (item.type === ContextMenuItemTypes.separator) {
$t.addClass('context-menu-separator ' + rootMenuData.classNames.notSelectable);
} else if (item.type === ContextMenuItemTypes.html) {
$t.addClass('context-menu-html ' + rootMenuData.classNames.notSelectable);
} else if (item.type && item.type !== ContextMenuItemTypes.submenu) {
$label = $('<label></label>').appendTo($t);
createNameNode(item).appendTo($label);
$t.addClass('context-menu-input');
currentMenuData.hasTypes = true;
$.each([currentMenuData, rootMenuData], function (i, k) {
k.commands[key] = item;
k.inputs[key] = item;
});
} else if (item.items) {
item.type = ContextMenuItemTypes.submenu;
}
switch (item.type) {
case ContextMenuItemTypes.separator:
break;
case ContextMenuItemTypes.text:
$input = $('<input type="text" value="1" name="" />')
.attr('name', 'context-menu-input-' + key)
.val(item.value || '')
.appendTo($label);
break;
case ContextMenuItemTypes.textarea:
$input = $('<textarea name=""></textarea>')
.attr('name', 'context-menu-input-' + key)
.val(item.value || '')
.appendTo($label);
if (item.height) {
$input.height(item.height);
}
break;
case ContextMenuItemTypes.checkbox:
$input = $('<input type="checkbox" value="1" name="" />')
.attr('name', 'context-menu-input-' + key)
.val(item.value || '')
.prop('checked', !!item.selected)
.prependTo($label);
break;
case ContextMenuItemTypes.radio:
$input = $('<input type="radio" value="1" name="" />')
.attr('name', 'context-menu-input-' + item.radio)
.val(item.value || '')
.prop('checked', !!item.selected)
.prependTo($label);
break;
case ContextMenuItemTypes.select:
$input = $('<select name=""></select>')
.attr('name', 'context-menu-input-' + key)
.appendTo($label);
if (item.options) {
$.each(item.options, function (value, text) {
$('<option></option>').val(value).text(text).appendTo($input);
});
$input.val(item.selected);
}
break;
case ContextMenuItemTypes.submenu:
createNameNode(item).appendTo($t);
item.appendTo = item.$node;
$t.data('contextMenu', item).addClass('context-menu-submenu');
item.callback = null;
// If item contains items, and this is a promise, we should create it later
// check if subitems is of type promise. If it is a promise we need to create
// it later, after promise has been resolved.
if (typeof item.items.then === 'function') {
// probably a promise, process it, when completed it will create the sub menu's.
rootMenuData.manager.operations.processPromises(e, item, rootMenuData, item.items);
} else {
// normal submenu.
rootMenuData.manager.operations.create(e, item, rootMenuData);
}
break;
case ContextMenuItemTypes.html:
$(item.html).appendTo($t);
break;
default:
$.each([currentMenuData, rootMenuData], function (i, k) {
k.commands[key] = item;
// Overwrite only if undefined or the item is appended to the rootMenuData. This so it
// doesn't overwrite callbacks of rootMenuData elements if the name is the same.
if ($.isFunction(item.callback) && (typeof k.callbacks[key] === 'undefined' || typeof currentMenuData.type === 'undefined')) {
k.callbacks[key] = item.callback;
}
});
createNameNode(item).appendTo($t);
break;
}
// disable key listener in <input>
if (item.type && item.type !== ContextMenuItemTypes.submenu && item.type !== ContextMenuItemTypes.html && item.type !== ContextMenuItemTypes.separator) {
$input
.on('focus', rootMenuData.manager.handler.focusInput)
.on('blur', rootMenuData.manager.handler.blurInput);
if (item.events) {
$input.on(item.events, currentMenuData);
}
}
// add icons
if (item.icon) {
if ($.isFunction(item.icon)) {
item._icon = item.icon.call(this, e, $t, key, item, currentMenuData, rootMenuData);
} else {
if (typeof (item.icon) === 'string' && item.icon.substring(0, 3) === 'fa-') {
// to enable font awesome
item._icon = rootMenuData.classNames.icon + ' ' + rootMenuData.classNames.icon + '--fa fa ' + item.icon;
} else {
item._icon = rootMenuData.classNames.icon + ' ' + rootMenuData.classNames.icon + '-' + item.icon;
}
}
$t.addClass(item._icon);
}
}
// cache contained elements
item.$input = $input;
item.$label = $label;
// attach item to menu
$t.appendTo(currentMenuData.$menu);
// Disable text selection
if (!currentMenuData.hasTypes && $.support.eventSelectstart) {
// browsers support user-select: none,
// IE has a special event for text-selection
// browsers supporting neither will not be preventing text-selection
$t.on('selectstart.disableTextSelect', currentMenuData.manager.handler.abortevent);
}
});
// attach contextMenu to <body> (to bypass any possible overflow:hidden issues on parents of the trigger element)
if (!currentMenuData.$node) {
currentMenuData.$menu.css('display', 'none').addClass('context-menu-rootMenuData');
}
currentMenuData.$menu.appendTo(currentMenuData.appendTo || document.body);
}
/**
* Resize the menu to its content.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {ContextMenuEvent} e
* @param {JQuery} $menu
* @param {boolean?} nested
*/
resize(e, $menu, nested) {
let domMenu;
// determine widths of submenus, as CSS won't grow them automatically
// position:absolute within position:absolute; min-width:100; max-width:200; results in width: 100;
// kinda sucks hard...
// determine width of absolutely positioned element
$menu.css({position: 'absolute', display: 'block'});
// don't apply yet, because that would break nested elements' widths
$menu.data('width',
(domMenu = $menu.get(0)).getBoundingClientRect
? Math.ceil(domMenu.getBoundingClientRect().width)
: $menu.outerWidth() + 1); // outerWidth() returns rounded pixels
// reset styles so they allow nested elements to grow/shrink naturally
$menu.css({
position: 'static',
minWidth: '0px',
maxWidth: '100000px'
});
// identify width of nested menus
$menu.find('> li > ul').each((index, element) => {
e.data.manager.operations.resize(e, $(element), true);
});
// reset and apply changes in the end because nested
// elements' widths wouldn't be calculatable otherwise
if (!nested) {
$menu.find('ul').addBack().css({
position: '',
display: '',
minWidth: '',
maxWidth: ''
}).outerWidth(function () {
return $(this).data('width');
});
}
}
/**
* Update the contextmenu, re-evaluates the whole menu (including disabled/visible callbacks)
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData?} currentMenuData
* @param {ContextMenuData?} rootMenuData
*/
update(e, currentMenuData, rootMenuData) {
const $trigger = this;
if (typeof rootMenuData === 'undefined') {
rootMenuData = currentMenuData;
rootMenuData.manager.operations.resize(e, currentMenuData.$menu);
}
// re-check disabled for each item
currentMenuData.$menu.children().each(function (index, element) {
let $item = $(element);
let key = $item.data('contextMenuKey');
let item = currentMenuData.items[key];
let disabled = ($.isFunction(item.disabled) && item.disabled.call($trigger, e, key, currentMenuData, rootMenuData)) || item.disabled === true;
let visible;
if ($.isFunction(item.visible)) {
visible = item.visible.call($trigger, e, key, currentMenuData, rootMenuData);
} else if (typeof item.visible !== 'undefined') {
visible = item.visible === true;
} else {
visible = true;
}
$item[visible ? 'show' : 'hide']();
// dis- / enable item
$item[disabled ? 'addClass' : 'removeClass'](rootMenuData.classNames.disabled);
if ($.isFunction(item.icon)) {
$item.removeClass(item._icon);
item._icon = item.icon.call(this, $trigger, $item, key, item);
$item.addClass(item._icon);
}
if (item.type) {
// dis- / enable input elements
$item.find('input, select, textarea').prop('disabled', disabled);
// update input states
switch (item.type) {
case ContextMenuItemTypes.text:
case ContextMenuItemTypes.textarea:
item.$input.val(item.value || '');
break;
case ContextMenuItemTypes.checkbox:
case ContextMenuItemTypes.radio:
item.$input.val(item.value || '').prop('checked', !!item.selected);
break;
case ContextMenuItemTypes.select:
item.$input.val((item.selected === 0 ? '0' : item.selected) || '');
break;
}
}
if (item.$menu) {
// update sub-menu
rootMenuData.manager.operations.update.call($trigger, e, item, rootMenuData);
}
});
}
/**
* Create the overlay layer so we can capture the click outside the menu and close it.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} menuData
* @param {number} zIndex
* @returns {jQuery}
*/
layer(e, menuData, zIndex) {
const $window = $(window);
// add transparent layer for click area
// filter and background for Internet Explorer, Issue #23
const $layer = menuData.$layer = $('<div id="context-menu-layer"></div>')
.css({
height: $window.height(),
width: $window.width(),
display: 'block',
position: 'fixed',
'z-index': zIndex,
top: 0,
left: 0,
opacity: 0,
filter: 'alpha(opacity=0)',
'background-color': '#000'
})
.data('contextMenuRoot', menuData)
.insertBefore(this)
.on('contextmenu', menuData.manager.handler.abortevent)
.on('mousedown', menuData.manager.handler.layerClick);
// IE6 doesn't know position:fixed;
if (typeof document.body.style.maxWidth === 'undefined') { // IE6 doesn't support maxWidth
$layer.css({
'position': 'absolute',
'height': $(document).height()
});
}
return $layer;
}
/**
* Process submenu promise.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} currentMenuData
* @param {ContextMenuData} rootMenuData
* @param {Promise} promise
*/
processPromises(e, currentMenuData, rootMenuData, promise) {
// Start
currentMenuData.$node.addClass(rootMenuData.classNames.iconLoadingClass);
function finishPromiseProcess(currentMenuData, rootMenuData, items) {
if (typeof rootMenuData.$menu === 'undefined' || !rootMenuData.$menu.is(':visible')) {
return;
}
currentMenuData.$node.removeClass(rootMenuData.classNames.iconLoadingClass);
currentMenuData.items = items;
rootMenuData.manager.operations.create(e, currentMenuData, rootMenuData); // Create submenu
rootMenuData.manager.operations.update(e, currentMenuData, rootMenuData); // Correctly update position if user is already hovered over menu item
rootMenuData.positionSubmenu.call(currentMenuData.$node, e, currentMenuData.$menu); // positionSubmenu, will only do anything if user already hovered over menu item that just got new subitems.
}
function errorPromise(currentMenuData, rootMenuData, errorItem) {
// User called promise.reject() with an error item, if not, provide own error item.
if (typeof errorItem === 'undefined') {
errorItem = {
'error': {
name: 'No items and no error item',
icon: 'context-menu-icon context-menu-icon-quit'
}
};
if (window.console) {
(console.error || console.log).call(console, 'When you reject a promise, provide an "items" object, equal to normal sub-menu items');
}
} else if (typeof errorItem === 'string') {
errorItem = {'error': {name: errorItem}};
}
finishPromiseProcess(currentMenuData, rootMenuData, errorItem);
}
function completedPromise(currentMenuData, rootMenuData, items) {
// Completed promise (dev called promise.resolve). We now have a list of items which can
// be used to create the rest of the context menu.
if (typeof items === 'undefined') {
// Null result, dev should have checked
errorPromise(undefined); // own error object
}
finishPromiseProcess(currentMenuData, rootMenuData, items);
}
// Wait for promise completion. .then(success, error, notify) (we don't track notify). Bind the currentMenuData
// and rootMenuData to avoid scope problems
promise.then(completedPromise.bind(this, currentMenuData, rootMenuData), errorPromise.bind(this, currentMenuData, rootMenuData));
}
/**
* Operation that will run after contextMenu showed on screen.
*
* @method
* @memberOf ContextMenuOperations
* @instance
*
* @param {JQuery.Event} e
* @param {ContextMenuData} menuData
* @return {undefined}
*/
activated(e, menuData) {
const $menu = menuData.$menu;
const $menuOffset = $menu.offset();
const winHeight = $(window).height();
const winScrollTop = $(window).scrollTop();
const menuHeight = $menu.height();
if (menuHeight > winHeight) {
$menu.css({
'height': winHeight + 'px',
'overflow-x': 'hidden',
'overflow-y': 'auto',
'top': winScrollTop + 'px'
});
} else if (($menuOffset.top < winScrollTop) || ($menuOffset.top + menuHeight > winScrollTop + winHeight)) {
$menu.css({
'top': '0px'
});
}
}
};