import ContextMenuOperations from './ContextMenuOperations'; import defaults from '../defaults'; import ContextMenuHtml5Builder from './ContextMenuHtml5Builder'; import ContextMenuEventHandler from './ContextMenuEventHandler'; export default class ContextMenu { /** * @constructor * @constructs ContextMenu * @classdesc The ContextMenu is the core class that manages contextmenu's. You can call this class directly and skip going through jQuery. * @class ContextMenu * * @example * // You can call this class directly and skip going through jQuery, although it still requires jQuery to run. * const manager = new ContextMenu(); * manager.execute("create", options); * * @property {ContextMenuOptions|Object} defaults * @property {ContextMenuEventHandler} handle * @property {ContextMenuOperations} operations * @property {Object<string, ContextMenuData>} menus * @property {number} counter - Internal counter to keep track of different menu's on the page. * @property {boolean} initialized - Flag the menu as initialized. */ constructor() { this.html5builder = new ContextMenuHtml5Builder(); this.defaults = defaults; this.handler = new ContextMenuEventHandler(); this.operations = new ContextMenuOperations(); this.namespaces = {}; this.initialized = false; this.menus = {}; this.counter = 0; } /** * @method execute * @memberOf ContextMenu * @instance * * @param {(string|ContextMenuOptions)} operation * @param {(string|ContextMenuOptions)} options * @return {ContextMenu} */ execute(operation, options) { const normalizedArguments = this.normalizeArguments(operation, options); operation = normalizedArguments.operation; options = normalizedArguments.options; switch (operation) { case 'update': // Updates visibility and such this.update(options); break; case 'create': // no selector no joy this.create(options); break; case 'destroy': this.destroy(options); break; case 'html5': this.html5(options); break; default: throw new Error('Unknown operation "' + operation + '"'); } return this; } /** * if <menuitem> is not handled by the browser, or options was a bool true, initialize $.contextMenu for them. * @method html5 * @memberOf ContextMenu * * @param {ContextMenuOptions|boolean} options */ html5(options) { options = this.buildOptions(options); const menuItemSupport = ('contextMenu' in document.body && 'HTMLMenuItemElement' in window); if (!menuItemSupport || (typeof options === 'boolean' && options === true)) { $('menu[type="context"]').each(function () { if (this.id) { $.contextMenu({ selector: '[contextmenu=' + this.id + ']', items: $.contextMenu.fromMenu(this) }); } }).css('display', 'none'); } } /** * Destroy the ContextMenu * @method destroy * @memberOf ContextMenu * * @param {ContextMenuOptions} options */ destroy(options) { options = this.buildOptions(options); let $visibleMenu; if (options._hasContext) { // get proper options const context = options.context; Object.keys(this.menus).forEach((ns) => { let o = this.menus[ns]; if (!o) { return true; } // Is this menu equest to the context called from if (!$(context).is(o.selector)) { return true; } $visibleMenu = $('.context-menu-list').filter(':visible'); if ($visibleMenu.length && $visibleMenu.data().contextMenuRoot.$trigger.is($(o.context).find(o.selector))) { $visibleMenu.trigger('contextmenu:hide', {force: true}); } if (this.menus[o.ns].$menu) { this.menus[o.ns].$menu.remove(); } delete this.menus[o.ns]; $(o.context).off(o.ns); return true; }); } else if (!options.selector) { $(document).off('.contextMenu .contextMenuAutoHide'); Object.keys(this.menus).forEach((ns) => { let o = this.menus[ns]; $(o.context).off(o.ns); }); this.namespaces = {}; this.menus = {}; this.counter = 0; this.initialized = false; $('#context-menu-layer, .context-menu-list').remove(); } else if (this.namespaces[options.selector]) { $visibleMenu = $('.context-menu-list').filter(':visible'); if ($visibleMenu.length && $visibleMenu.data().contextMenuRoot.$trigger.is(options.selector)) { $visibleMenu.trigger('contextmenu:hide', {force: true}); } if (this.menus[this.namespaces[options.selector]].$menu) { this.menus[this.namespaces[options.selector]].$menu.remove(); } delete this.menus[this.namespaces[options.selector]]; $(document).off(this.namespaces[options.selector]); } this.handler.$currentTrigger = null; } /** * Create a ContextMenu * @method create * @memberOf ContextMenu * * @param {ContextMenuOptions} options */ create(options) { options = this.buildOptions(options); if (!options.selector) { throw new Error('No selector specified'); } // make sure internal classes are not bound to if (options.selector.match(/.context-menu-(list|item|input)($|\s)/)) { throw new Error('Cannot bind to selector "' + options.selector + '" as it contains a reserved className'); } if (!options.build && (!options.items || $.isEmptyObject(options.items))) { throw new Error('No Items specified'); } this.counter++; options.ns = '.contextMenu' + this.counter; if (!options._hasContext) { this.namespaces[options.selector] = options.ns; } this.menus[options.ns] = options; // default to right click if (!options.trigger) { options.trigger = 'right'; } if (!this.initialized) { const itemClick = options.itemClickEvent === 'click' ? 'click.contextMenu' : 'mouseup.contextMenu'; const contextMenuItemObj = { // 'mouseup.contextMenu': this.handler.itemClick, // 'click.contextMenu': this.handler.itemClick, 'contextmenu:focus.contextMenu': this.handler.focusItem, 'contextmenu:blur.contextMenu': this.handler.blurItem, 'contextmenu.contextMenu': this.handler.abortevent, 'mouseenter.contextMenu': this.handler.itemMouseenter, 'mouseleave.contextMenu': this.handler.itemMouseleave }; contextMenuItemObj[itemClick] = this.handler.itemClick; // make sure item click is registered first $(document) .on({ 'contextmenu:hide.contextMenu': this.handler.hideMenu, 'prevcommand.contextMenu': this.handler.prevItem, 'nextcommand.contextMenu': this.handler.nextItem, 'contextmenu.contextMenu': this.handler.abortevent, 'mouseenter.contextMenu': this.handler.menuMouseenter, 'mouseleave.contextMenu': this.handler.menuMouseleave }, '.context-menu-list') .on('mouseup.contextMenu', '.context-menu-input', this.handler.inputClick) .on(contextMenuItemObj, '.context-menu-item'); this.initialized = true; } // engage native contextmenu event options.context .on('contextmenu' + options.ns, options.selector, options, this.handler.contextmenu); switch (options.trigger) { case 'hover': options.context .on('mouseenter' + options.ns, options.selector, options, this.handler.mouseenter) .on('mouseleave' + options.ns, options.selector, options, this.handler.mouseleave); break; case 'left': options.context.on('click' + options.ns, options.selector, options, this.handler.click); break; case 'touchstart': options.context.on('touchstart' + options.ns, options.selector, options, this.handler.click); break; /* default: // http://www.quirksmode.org/dom/events/contextmenu.html $document .on('mousedown' + o.ns, o.selector, o, this.handler.mousedown) .on('mouseup' + o.ns, o.selector, o, this.handler.mouseup); break; */ } // create menu if (!options.build) { this.operations.create(null, options); } } /** * Update the ContextMenu or all ContextMenu's * @method update * @memberOf ContextMenu * * @param {ContextMenuOptions} options */ update(options) { options = this.buildOptions(options); if (options._hasContext) { this.operations.update(null, $(options.context).data('contextMenu'), $(options.context).data('contextMenuRoot')); } else { for (let menu in this.menus) { if (this.menus.hasOwnProperty(menu)) { this.operations.update(null, this.menus[menu]); } } } } /** * Build the options, by applying the Manager, defaults, user options and normalizing the context. * @method buildOptions * @memberOf ContextMenu * * @param {ContextMenuOptions} userOptions * @return {ContextMenuOptions} */ buildOptions(userOptions) { if (typeof userOptions === 'string') { userOptions = {selector: userOptions}; } const options = $.extend(true, {manager: this}, this.defaults, userOptions); if (!options.context || !options.context.length) { options.context = $(document); options._hasContext = false; } else { // you never know what they throw at you... options.context = $(options.context).first(); options._hasContext = !$(options.context).is($(document)); } return options; } /** * @method normalizeArguments * @memberOf ContextMenu * * @param {string|Object} operation * @param {string|Object|ContextMenuOptions} options * @returns {{operation: string, options: ContextMenuOptions}} */ normalizeArguments(operation, options) { if (typeof operation !== 'string') { options = operation; operation = 'create'; } if (typeof options === 'string') { options = {selector: options}; } else if (typeof options === 'undefined') { options = {}; } return {operation, options}; } /** * import values into `<input>` commands * * @method setInputValues * @memberOf ContextMenu * @instance * * @param {ContextMenuData} contextMenuData - {@link ContextMenuData} object * @param {Object} data - Values to set * @return {undefined} */ setInputValues(contextMenuData, data) { if (typeof data === 'undefined') { data = {}; } $.each(contextMenuData.inputs, function (key, item) { switch (item.type) { case 'text': case 'textarea': item.value = data[key] || ''; break; case 'checkbox': item.selected = !!data[key]; break; case 'radio': item.selected = (data[item.radio] || '') === item.value; break; case 'select': item.selected = data[key] || ''; break; } }); } /** * export values from `<input>` commands * * @method getInputValues * @memberOf ContextMenu * @instance * * @param {ContextMenuData} contextMenuData - {@link ContextMenuData} object * @param {Object} data - Values object * @return {Object} - Values of input elements */ getInputValues(contextMenuData, data) { if (typeof data === 'undefined') { data = {}; } $.each(contextMenuData.inputs, function (key, item) { switch (item.type) { case 'text': case 'textarea': case 'select': data[key] = item.$input.val(); break; case 'checkbox': data[key] = item.$input.prop('checked'); break; case 'radio': if (item.$input.prop('checked')) { data[item.radio] = item.value; } break; } }); return data; } }