nw-skeleton

Source: app-wrapper/js/lib/main/menuHelper.js

/**
 * @fileOverview MenuHelper main class file
 * @author Dino Ivankov <dinoivankov@gmail.com>
 * @version 1.3.1
 */

const _ = require('lodash');
const MainBaseClass = require('./mainBase').MainBaseClass;

/**
 * MenuHelper class - handles and manages app menus and tray
 *
 * @class
 * @extends {mainScript.MainBaseClass}
 * @memberof mainScript
 * @property {boolean}  hasMacBuiltin   Flag to indicate whether the app has mac builtin menu
 * @property {boolean}  hasEditMenu     Flag to indicate whether the app has "Edit" menu
 * @property {Object}   tray             Object containing nw.tray instance - see {@link http://docs.nwjs.io/en/latest/References/Tray/}
 * @property {Object}   trayMenu         Object containing tray menu data - see {@link http://docs.nwjs.io/en/latest/References/Tray/#traymenu}
 * @property {Object}   menu             Object containing nw.menu instance - see {@link http://docs.nwjs.io/en/latest/References/Menu/}
 * @property {array}    menuMethodMap    Array containing map of handlers by menu position
 * @property {array}    menuShortcutMap  Array containing map of key shortcuts by menu position
 * @property {array}    userShortcuts    Array with all used shortcuts so far (used to warn about duplicate shortcuts)
 */
class MenuHelper extends MainBaseClass {

    /**
     * Creates MenuHelper instance
     *
     * @constructor
     * @return {MenuHelper}              Instance of MenuHelper class
     */
    constructor() {
        super();

        this.hasMacBuiltin = false;
        this.hasEditMenu = false;
        this.hasWindowMenu = false;
        this.tray = null;
        this.trayMenu = null;
        this.menu = null;
        this.menuMethodMap = [];
        this.menuShortcutMap = [];
        this.usedShortcuts = [];

        this.menuInitialized = false;
        this.menuSetup = false;
        this.trayInitialized = false;

        return this;
    }

    /**
     * Initializes app menu using data from config
     *
     * @async
     * @return {undefined}
     */
    async initializeAppMenu() {
        if (!this.menuInitialized){
            this.log('Initializing app menu', 'debug', []);
            let _appWrapper = this.getAppWrapper();
            if (!(!_.isUndefined(this.menu) && this.menu)){
                let menuData = this.getConfig('appConfig.menuData');
                let hasAppMenu = this.getConfig('appConfig.hasAppMenu');
                if (hasAppMenu){
                    if (menuData && menuData.mainItemName && menuData.options){
                        if (!this.menu){
                            this.menu = new nw.Menu({type: 'menubar'});
                            if (_appWrapper.isMac() && !this.hasMacBuiltin){
                                this.menu.createMacBuiltin(menuData.mainItemName, menuData.options);
                                this.hasMacBuiltin = true;
                            }
                            this.hasEditMenu = !menuData.options.hideEdit;
                            this.hasWindowMenu = !menuData.options.hideWindow;
                        }
                    }
                } else {
                    if (_appWrapper.isMac()){
                        if (menuData && menuData.mainItemName && menuData.options){
                            if (!this.menu){
                                this.menu = new nw.Menu({type: 'menubar'});
                                if (!this.hasMacBuiltin){
                                    this.menu.createMacBuiltin(menuData.mainItemName, menuData.options);
                                    this.hasMacBuiltin = true;
                                }
                                this.hasEditMenu = !menuData.options.hideEdit;
                                this.hasWindowMenu = !menuData.options.hideWindow;
                            }
                        }
                    }
                }
            }
            this.menuInitialized = true;
        } else {
            this.log('App menu already initialized', 'debug', []);
        }
    }

    /**
     * Reinitializes app menu using data from config
     *
     * @async
     * @return {undefined}
     */
    async reinitializeAppMenu() {
        if (this.menuInitialized){
            await this.removeAppMenu();
        }
        await this.initializeAppMenu();
        await this.setupAppMenu();
    }

    /**
     * Sets up app menu using configuration data
     *
     * @async
     * @return {undefined}
     */
    async setupAppMenu() {
        if (!this.menuSetup){
            this.log('Setfting up app menu', 'debug', []);
            let _appWrapper = this.getAppWrapper();
            let hasAppMenu = this.getConfig('appConfig.hasAppMenu');
            if (hasAppMenu){
                if (!_appWrapper.isMac() && !mainScript.mainWindow.window.appState.windowState.frame){
                    this.log('You should not be using frameless window with app menus.', 'warning', [], false, true);
                    return;
                }
                let menuData = this.getConfig('appConfig.menuData');
                this.menuMethodMap = [];
                if (menuData && menuData.menus && _.isArray(menuData.menus) && menuData.menus.length){

                    if (_appWrapper.isMac()){
                        let firstMenuChunk = [];
                        let secondMenuChunk = [];
                        let thirdMenuChunk = [];
                        if (!this.hasMacBuiltin && !this.hasEditMenu && this.hasWindowMenu){
                            firstMenuChunk = _.slice(menuData.menus, 0);
                        } else {
                            if (this.hasMacBuiltin) {
                                firstMenuChunk = _.slice(menuData.menus, 0, 1);
                            }
                            if (!this.hasEditMenu && !this.hasWindowMenu){
                                thirdMenuChunk = _.slice(menuData.menus, 1);
                            } else {
                                if (this.hasEditMenu && this.hasWindowMenu){
                                    secondMenuChunk = _.slice(menuData.menus, 1, 2);
                                    thirdMenuChunk = _.slice(menuData.menus, 3);
                                } else {
                                    secondMenuChunk = _.slice(menuData.menus, 1, 1);
                                    thirdMenuChunk = _.slice(menuData.menus, 2);
                                }
                            }
                        }
                        menuData.menus = _.concat(firstMenuChunk, secondMenuChunk, thirdMenuChunk);
                    }
                    for(let i=0; i<menuData.menus.length; i++){
                        let menuMethodData = await this.initializeAppMenuItemData(menuData.menus[i], i);
                        this.menuMethodMap = _.union(this.menuMethodMap, menuMethodData);
                        this.menu.append(await this.initializeAppMenuItem(menuData.menus[i], i));
                    }
                }
                _appWrapper.windowManager.setMenu(this.menu);
            } else if (_appWrapper.isMac()){
                _appWrapper.windowManager.setMenu(this.menu);
            }
            this.menuSetup = true;
        } else {
            this.log('App menu already set up', 'debug', []);
        }
    }

    /**
     * Initializes single app menu item
     *
     * @async
     * @param  {Object} menuItemData Menu item data
     * @param  {string} menuIndex    Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @return {Object}              Instance of nw.menuItem - see {@link http://docs.nwjs.io/en/latest/References/MenuItem/}
     */
    async initializeAppMenuItem (menuItemData, menuIndex) {
        let menuItem;
        let menuItemObj = _.extend(menuItemData.menuItem, {
            click: this.handleMenuClick.bind(this, menuIndex, menuItemData.menuItem)
        });
        if (menuItemData.menuItem.shortcut){
            let modifiers = [];
            if (menuItemData.menuItem.shortcut.key){
                menuItemObj.key = menuItemData.menuItem.shortcut.key;
            }
            if (menuItemData.menuItem.shortcut.modifiers){
                if (menuItemData.menuItem.shortcut.modifiers.ctrl){
                    if (this.getAppWrapper().isMac()){
                        modifiers.push('cmd');
                    } else {
                        modifiers.push('ctrl');
                    }
                }
                if (menuItemData.menuItem.shortcut.modifiers.alt){
                    modifiers.push('alt');
                }
                if (menuItemData.menuItem.shortcut.modifiers.shift){
                    modifiers.push('shift');
                }
            }
            menuItemObj.modifiers = modifiers.join('+');
            let shortcutIdentifier = modifiers.join('+') + '+' + (menuItemObj.key + '').toLowerCase();
            if (_.includes(this.usedShortcuts, shortcutIdentifier)){
                this.log('Double shortcut "{1}" found for menuItem "{2}", ignoring!', 'warning', [shortcutIdentifier, menuItemObj.label], false, true);
                menuItemObj.modifiers = [];
                menuItemObj.key = null;
            } else {
                this.usedShortcuts.push(shortcutIdentifier);
            }
        }
        if (menuItemData.children && menuItemData.children.length){
            let submenu = new nw.Menu();
            for(let i=0; i<menuItemData.children.length; i++){
                submenu.append(await this.initializeAppMenuItem(menuItemData.children[i], menuIndex + '_' + i));
            }

            menuItem = new nw.MenuItem(_.extend(menuItemObj, {submenu: submenu}));
        } else {
            menuItem = new nw.MenuItem(_.extend(menuItemObj));
        }
        return menuItem;
    }

    /**
     * Initializes single app menu item data using configuration data
     *
     * @async
     * @param  {Object} menuItemData Menu item data from configuration
     * @param  {string} menuIndex    Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @return {Object[]}            Array with single member - menuItemData object
     */
    async initializeAppMenuItemData (menuItemData, menuIndex) {
        let menuData = [];
        menuIndex = menuIndex + '';
        if (menuItemData.menuItem.type != 'separator'){
            let menuMethod;
            if (menuItemData.menuItem && menuItemData.menuItem.method) {
                menuMethod = await this.getAppWrapper().getObjMethod(menuItemData.menuItem.method, [], this.getAppWrapper(), true);
            }
            if (!menuMethod){
                this.log('Can not find method "{1}" for menu item with label "{2}"!', 'error', [menuItemData.menuItem.method, menuItemData.menuItem.label], false, true);
            }

            menuData.push({
                menuIndex: menuIndex,
                label: menuItemData.menuItem.label,
                shortcut: menuItemData.menuItem.shortcut,
                method: menuItemData.menuItem.method
            });
            if (menuItemData.children && menuItemData.children.length){
                for(let i=0; i<menuItemData.children.length; i++){
                    menuData = _.union(menuData, await this.initializeAppMenuItemData(menuItemData.children[i], menuIndex + '_' + i));
                }
            }
        } else {
            menuData.push({
                menuIndex: menuIndex,
                label: '__separator__',
                shortcut: null,
                method: 'noop'
            });
        }
        return menuData;
    }

    /**
     * Gets menu item by its index
     *
     * @param  {string} menuItemIndex   Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @return {Object}                 Menu item information from this.menuMethodMap
     */
    getMenuItem (menuItemIndex){
        menuItemIndex = menuItemIndex + '';
        let menuItem = _.find(this.menuMethodMap, {menuIndex: menuItemIndex});
        if (!menuItem){
            this.log('Can not find menu item {1}', 'warning', [menuItemIndex], false, true);
        }
        return menuItem;
    }


    /**
     * Gets menu item by its parent index, returning children as well
     *
     * @param  {string} menuItemIndex   Menu item index
     * @param  {string} parentIndex     Menu item parent index
     * @return {Object}                 Menu item information from this.menuMethodMap
     */
    getMenuItemChain (menuItemIndex, parentIndex){
        menuItemIndex = menuItemIndex + '';
        let menuItemIndices = menuItemIndex.split('_');
        let currentIndex = menuItemIndices[0] + '';
        if (parentIndex){
            currentIndex = parentIndex + '_' + menuItemIndices[0];
        }

        let currentItem = _.find(this.menuMethodMap, {menuIndex: currentIndex});

        if (menuItemIndices.length > 1){
            parentIndex = currentIndex;
            currentIndex = _.tail(menuItemIndices).join('_');
            currentItem.child = this.getMenuItemChain(currentIndex, parentIndex);
        }
        return currentItem;
    }

    /**
     * Returns array of labels containing all parent labels up to current menu item label
     *
     * @param  {string} menuItemIndex   Menu item index in format parentIndex_menuIndex (i.e '1_2')
     * @return {string[]}               Array of menu labels
     */
    getMenuItemLabelPaths (menuItemIndex){
        let itemChain = this.getMenuItemChain(menuItemIndex);
        let paths = this.getChainLabelPath(itemChain);
        return paths;
    }

    /**
     * Returns array of labels containing all parent labels up to current menu item label
     *
     * @param  {array} itemChain        Menu item chain, containing all parent items up to current menu item
     * @param  {string[]} labelPaths    Array of menu labels
     * @return {string[]}               Array of menu labels
     */
    getChainLabelPath (itemChain, labelPaths) {
        if (!labelPaths){
            labelPaths = [];
        }
        if (itemChain && itemChain.label){
            labelPaths.push(itemChain.label);
            if (itemChain && itemChain.child){
                labelPaths = _.union(labelPaths, this.getChainLabelPath(itemChain.child, labelPaths));
            }
        }
        return labelPaths;
    }

    /**
     * Returns listener method name for current menu item
     *
     * @param  {string} menuItemIndex   Menu item index in format parentIndex_menuIndex (i.e '1_2')
     * @return {string}                 Listener method name
     */
    getMenuItemMethodName (menuItemIndex){
        menuItemIndex = menuItemIndex + '';
        let method;
        let menuMethod = _.find(this.menuMethodMap, {menuIndex: menuItemIndex});
        if (menuMethod && menuMethod.method) {
            method = menuMethod.method;
        } else {
            this.log('Can not find method for menu item {1}', 'warning', [menuItemIndex], false, true);
        }
        return method;
    }

    /**
     * Removes app menu
     *
     * @async
     * @return {undefined}
     */
    async removeAppMenu (){
        let start = 0;
        if (this.hasMacBuiltin){
            start++;
        }
        if (this.hasEditMenu){
            start++;
        }
        if (this.hasWindowMenu){
            start++;
        }
        if (this.menuSetup && this.menu && this.menu.items){

            for(let i=start; i<this.menu.items.length;i++){
                this.menu.removeAt(i);
            }
            for(let i=start; i<this.menu.items.length;i++){
                this.menu.removeAt(i);
            }
        }
        this.menuSetup = false;
        this.menuInitialized = false;
        // this.hasMacBuiltin = false;
        // this.hasEditMenu = false;
        // this.menu = null;
        this.menuMethodMap = [];
        this.menuShortcutMap = [];
        this.usedShortcuts = [];

    }

    /**
     * Handles click on menu items
     *
     * @param  {string} menuIndex   Menu item index in format parentIndex_menuIndex (i.e '1_2')
     * @return {mixed}              Listener return value or false if no listener found
     */
    handleMenuClick (menuIndex) {
        let originalMenuIndex = menuIndex;
        let methodIdentifier = this.getMenuItemMethodName(menuIndex);
        let menuItem = this.getMenuItem(menuIndex);
        let objectIdentifier;
        let method;
        let object = this.getAppWrapper();
        if (methodIdentifier && _.isFunction(methodIdentifier.match) && methodIdentifier.match(/\./)){
            objectIdentifier = methodIdentifier.replace(/\.[^.]+$/, '');
        }

        if (methodIdentifier){
            method = _.get(this.getAppWrapper(), methodIdentifier);
        }

        if (objectIdentifier){
            object = _.get(this.getAppWrapper(), objectIdentifier);
        }

        let label = this.getMenuItemLabelPaths(originalMenuIndex).join(' > ');
        if (!methodIdentifier){
            methodIdentifier = 'unknown';
        } else {
            methodIdentifier = 'appWrapper.' + methodIdentifier;
        }

        if (object && method && _.isFunction(method)){
            this.log('Calling menu click handler "{1}" for menuItem "{2}", menuIndex "{3}"!', 'debug', [methodIdentifier, label, menuIndex]);
            return method.call(object, menuItem);
        } else {
            this.log('Can\'t call menu click handler "{1}" for menuItem "{2}", menuIndex "{3}"!', 'error', [methodIdentifier, label, menuIndex], false, true);
            return false;
        }
    }

    /**
     * Handles click on tray menu items
     *
     * @param  {string} trayMenuItem    Tray menu item index in format parentIndex_menuIndex (i.e '1_2')
     * @return {mixed}                  Listener return value or false if no listener found
     */
    handleTrayClick (trayMenuItem) {
        let methodIdentifier = trayMenuItem.method;
        let objectIdentifier;
        let method;

        this.log(this.getTrayMenuItem('1_0'));
        if (methodIdentifier){
            let object = this.getAppWrapper();
            if (methodIdentifier && _.isFunction(methodIdentifier.match) && methodIdentifier.match(/\./)){
                objectIdentifier = methodIdentifier.replace(/\.[^.]+$/, '');
            }

            if (methodIdentifier){
                method = _.get(this.getAppWrapper(), methodIdentifier);
            }

            if (objectIdentifier){
                object = _.get(this.getAppWrapper(), objectIdentifier);
            }

            if (!methodIdentifier){
                methodIdentifier = 'unknown';
            } else {
                methodIdentifier = 'appWrapper.' + methodIdentifier;
            }

            if (object && method && _.isFunction(method)){
                this.log('Calling tray menu click handler "{1}" for menuItem "{2}"', 'debug', [methodIdentifier, trayMenuItem.label]);
                let appState = this.getAppState();
                let appWrapper = this.getAppWrapper();
                if (appWrapper && appState){
                    if (!appState.status.windowFocused){
                        appWrapper.windowManager.focusWindow();
                    }
                }
                return method.call(object, trayMenuItem);
            } else {
                this.log('Can not call tray menu click handler "{1}" for menuItem "{2}"!', 'error', [methodIdentifier, trayMenuItem.label], false, true);
                return false;
            }
        }
    }

    /**
     * Initializes single tray menu item
     *
     * @async
     * @param  {Object} menuItemData Menu item data
     * @return {Object}              Instance of nw.menuItem - see {@link http://docs.nwjs.io/en/latest/References/MenuItem/}
     */
    async initializeTrayMenuItem (menuItemData) {
        let menuItem;
        if (menuItemData.type != 'separator'){
            if (menuItemData.label){
                menuItemData.label = this.getAppWrapper().appTranslations.translate(menuItemData.label);
            }
            if (menuItemData.tooltip){
                menuItemData.tooltip = this.getAppWrapper().appTranslations.translate(menuItemData.tooltip);
            }
        }
        let menuItemObj = _.extend(menuItemData, {
            click: this.handleTrayClick.bind(this, menuItemData)
        });
        if (menuItemData.children && menuItemData.children.length){
            let submenu = new nw.Menu();
            for(let i=0; i<menuItemData.children.length; i++){
                submenu.append(await this.initializeTrayMenuItem(menuItemData.children[i]));
            }
            menuItem = new nw.MenuItem(_.extend(menuItemObj, {submenu: submenu}));
        } else {
            menuItem = new nw.MenuItem(menuItemObj);
        }
        return menuItem;
    }

    /**
     * Initializes app tray icon
     *
     * @async
     * @return {undefined}
     */
    async initializeTrayIcon(){
        if (!this.trayInitialized){
            this.log('Initializing tray icon', 'debug', []);
            let hasTrayIcon = this.getConfig('appConfig.hasTrayIcon');
            if (hasTrayIcon){
                let trayData = _.cloneDeep(this.getConfig('appConfig.trayData'));
                let trayOptions = {
                    title: trayData.title,
                    icon: trayData.icon
                };
                if (trayData.alticon){
                    trayOptions.alticon = trayData.alticon;
                }
                this.tray = new nw.Tray(trayOptions);
                if (trayData.menus && trayData.menus.length){
                    this.trayMenu = new nw.Menu();
                    for (let i=0; i<trayData.menus.length; i++){
                        let menuItem = await this.initializeTrayMenuItem(trayData.menus[i]);
                        this.trayMenu.append(menuItem);
                    }
                    this.tray.menu = this.trayMenu;
                }
            }
            this.trayInitialized = true;
        } else {
            this.log('Tray icon already initialized', 'debug', []);
        }
    }

    /**
     * Removes app tray icon
     *
     * @async
     * @return {undefined}
     */
    async removeTrayIcon(){
        if (this.trayInitialized && this.tray && this.tray.remove && _.isFunction(this.tray.remove)){
            this.tray.remove();
            this.trayMenu = null;
            this.tray = null;
        }
        this.trayInitialized = false;
    }

    /**
     * Reinitializes app tray icon
     *
     * @async
     * @return {undefined}
     */
    async reinitializeTrayIcon() {
        if (this.trayInitialized){
            await this.removeTrayIcon();
        }
        await this.initializeTrayIcon();
    }

    /**
     * Returns tray menu item by its index
     *
     * @param  {string}     menuItemIndex   Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @param  {Object[]}   menuItems       Optional menuItems array (for nested calls)
     * @return {(Object|undefined)}         MenuItem object or undefined if none found
     */
    getTrayMenuItem(menuItemIndex, menuItems){
        if (!menuItems){
            menuItems = this.trayMenu.items;
        }
        let indexChunks = menuItemIndex.split('_');
        let menuItem;
        if (indexChunks && indexChunks.length){
            menuItem = menuItems[indexChunks[0]];
            if (indexChunks.length > 1 && menuItem && menuItem.submenu && menuItem.submenu.items){
                menuItemIndex = _.tail(indexChunks).join('_');
                menuItems = menuItem.submenu.items;
                menuItem = this.getTrayMenuItem(menuItemIndex, menuItems);
            }
        }
        if (!menuItem){
            this.log('Can not find tray menu item for index "{1}" {2}', 'error', [menuItemIndex, menuItems && menuItems.length ? 'has menuItems' : '']);
        }
        return menuItem;
    }

    /**
     * Updates tray menu item for given menuIndex by merging it with menuItemUpdates
     *
     * @param  {string} menuItemIndex   Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @param  {Object} menuItemUpdates Object with values to update
     * @return {Object}                 Updated menuItem
     */
    updateTrayMenuItem(menuItemIndex, menuItemUpdates){
        let menuItem = this.getTrayMenuItem(menuItemIndex);
        let appWrapper = this.getAppWrapper();
        if (appWrapper && menuItem){
            _.merge(menuItem, menuItemUpdates);
        }
        return menuItem;
    }

    /**
     * Returns app menu item by its index
     *
     * @param  {string}     menuItemIndex   Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @param  {Object[]}   menuItems       Optional menuItems array (for nested calls)
     * @return {(Object|undefined)}         MenuItem object or undefined if none found
     */
    getAppMenuItem(menuItemIndex, menuItems){
        if (!menuItems){
            menuItems = this.menu.items;
        }
        let indexChunks = menuItemIndex.split('_');
        let menuItem;
        if (indexChunks && indexChunks.length){
            menuItem = menuItems[indexChunks[0]];
            if (indexChunks.length > 1 && menuItem && menuItem.submenu && menuItem.submenu.items){
                menuItemIndex = _.tail(indexChunks).join('_');
                menuItems = menuItem.submenu.items;
                menuItem = this.getAppMenuItem(menuItemIndex, menuItems);
            }
        }
        if (!menuItem){
            this.log('Can not find app menu item for index "{1}" {2}', 'error', [menuItemIndex, menuItems && menuItems.length ? 'has menuItems' : '']);
        }
        return menuItem;
    }

    /**
     * Updates app menu item for given menuIndex by merging it with menuItemUpdates
     *
     * @param  {string} menuItemIndex   Menu index in format parentIndex_menuIndex (i.e '1_2')
     * @param  {Object} menuItemUpdates Object with values to update
     * @return {Object}                 Updated menuItem
     */
    updateAppMenuItem(menuItemIndex, menuItemUpdates){
        let menuItem = this.getAppMenuItem(menuItemIndex);
        let appWrapper = this.getAppWrapper();
        if (appWrapper && menuItem){
            _.merge(menuItem, menuItemUpdates);
        }
        return menuItem;
    }


}

exports.MenuHelper = MenuHelper;