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' }); } } };