Source: classes/ContextMenu.js

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