adobe/brackets

View on GitHub
src/command/Menus.js

Summary

Maintainability
F
6 days
Test Coverage
/*
 * Copyright (c) 2012 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 *
 */

define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    // Load dependent modules
    var Commands            = require("command/Commands"),
        EventDispatcher     = require("utils/EventDispatcher"),
        KeyBindingManager   = require("command/KeyBindingManager"),
        StringUtils         = require("utils/StringUtils"),
        CommandManager      = require("command/CommandManager"),
        PopUpManager        = require("widgets/PopUpManager"),
        ViewUtils           = require("utils/ViewUtils"),
        DeprecationWarning  = require("utils/DeprecationWarning");

    // make sure the global brackets variable is loaded
    require("utils/Global");

    /**
     * Brackets Application Menu Constants
     * @enum {string}
     */
    var AppMenuBar = {
        FILE_MENU       : "file-menu",
        EDIT_MENU       : "edit-menu",
        FIND_MENU       : "find-menu",
        VIEW_MENU       : "view-menu",
        NAVIGATE_MENU   : "navigate-menu",
        HELP_MENU       : "help-menu"
    };

    /**
     * Brackets Context Menu Constants
     * @enum {string}
     */
    var ContextMenuIds = {
        EDITOR_MENU:                    "editor-context-menu",
        INLINE_EDITOR_MENU:             "inline-editor-context-menu",
        PROJECT_MENU:                   "project-context-menu",
        WORKING_SET_CONTEXT_MENU:       "workingset-context-menu",
        WORKING_SET_CONFIG_MENU:        "workingset-configuration-menu",
        SPLITVIEW_MENU:                 "splitview-menu"
    };

    /**
     * Brackets Application Menu Section Constants
     * It is preferred that plug-ins specify the location of new MenuItems
     * in terms of a menu section rather than a specific MenuItem. This provides
     * looser coupling to Bracket's internal MenuItems and makes menu organization
     * more semantic.
     * Use these constants as the "relativeID" parameter when calling addMenuItem() and
     * specify a position of FIRST_IN_SECTION or LAST_IN_SECTION.
     *
     * Menu sections are denoted by dividers or the beginning/end of a menu
     */
    var MenuSection = {
        // Menu Section                     Command ID to mark the section
        FILE_OPEN_CLOSE_COMMANDS:           {sectionMarker: Commands.FILE_NEW},
        FILE_SAVE_COMMANDS:                 {sectionMarker: Commands.FILE_SAVE},
        FILE_LIVE:                          {sectionMarker: Commands.FILE_LIVE_FILE_PREVIEW},
        FILE_EXTENSION_MANAGER:             {sectionMarker: Commands.FILE_EXTENSION_MANAGER},

        EDIT_UNDO_REDO_COMMANDS:            {sectionMarker: Commands.EDIT_UNDO},
        EDIT_TEXT_COMMANDS:                 {sectionMarker: Commands.EDIT_CUT},
        EDIT_SELECTION_COMMANDS:            {sectionMarker: Commands.EDIT_SELECT_ALL},
        EDIT_MODIFY_SELECTION:              {sectionMarker: Commands.EDIT_INDENT},
        EDIT_COMMENT_SELECTION:             {sectionMarker: Commands.EDIT_LINE_COMMENT},
        EDIT_CODE_HINTS_COMMANDS:           {sectionMarker: Commands.SHOW_CODE_HINTS},
        EDIT_TOGGLE_OPTIONS:                {sectionMarker: Commands.TOGGLE_CLOSE_BRACKETS},

        FIND_FIND_COMMANDS:                 {sectionMarker: Commands.CMD_FIND},
        FIND_FIND_IN_COMMANDS:              {sectionMarker: Commands.CMD_FIND_IN_FILES},
        FIND_REPLACE_COMMANDS:              {sectionMarker: Commands.CMD_REPLACE},

        VIEW_HIDESHOW_COMMANDS:             {sectionMarker: Commands.VIEW_HIDE_SIDEBAR},
        VIEW_FONTSIZE_COMMANDS:             {sectionMarker: Commands.VIEW_INCREASE_FONT_SIZE},
        VIEW_TOGGLE_OPTIONS:                {sectionMarker: Commands.TOGGLE_ACTIVE_LINE},

        NAVIGATE_GOTO_COMMANDS:             {sectionMarker: Commands.NAVIGATE_QUICK_OPEN},
        NAVIGATE_DOCUMENTS_COMMANDS:        {sectionMarker: Commands.NAVIGATE_NEXT_DOC},
        NAVIGATE_OS_COMMANDS:               {sectionMarker: Commands.NAVIGATE_SHOW_IN_FILE_TREE},
        NAVIGATE_QUICK_EDIT_COMMANDS:       {sectionMarker: Commands.TOGGLE_QUICK_EDIT},
        NAVIGATE_QUICK_DOCS_COMMANDS:       {sectionMarker: Commands.TOGGLE_QUICK_DOCS}
    };


    /**
     * Insertion position constants
     * Used by addMenu(), addMenuItem(), and addSubMenu() to
     * specify the relative position of a newly created menu object
     * @enum {string}
     */
    var BEFORE           = "before",
        AFTER            = "after",
        FIRST            = "first",
        LAST             = "last",
        FIRST_IN_SECTION = "firstInSection",
        LAST_IN_SECTION  = "lastInSection";

    /**
     * Other constants
     */
    var DIVIDER = "---";
    var SUBMENU = "SUBMENU";

    /**
     * Error Codes from Brackets Shell
     * @enum {number}
     */
    var NO_ERROR           = 0,
        ERR_UNKNOWN        = 1,
        ERR_INVALID_PARAMS = 2,
        ERR_NOT_FOUND      = 3;

    /**
     * Maps menuID's to Menu objects
     * @type {Object.<string, Menu>}
     */
    var menuMap = {};

    /**
     * Maps contextMenuID's to ContextMenu objects
     * @type {Object.<string, ContextMenu>}
     */
    var contextMenuMap = {};

    /**
     * Maps menuItemID's to MenuItem objects
     * @type {Object.<string, MenuItem>}
     */
    var menuItemMap = {};

    /**
     * Retrieves the Menu object for the corresponding id.
     * @param {string} id
     * @return {Menu}
     */
    function getMenu(id) {
        return menuMap[id];
    }

    /**
     * Retrieves the map of all Menu objects.
     * @return {Object.<string, Menu>}
     */
    function getAllMenus() {
        return menuMap;
    }

    /**
     * Retrieves the ContextMenu object for the corresponding id.
     * @param {string} id
     * @return {ContextMenu}
     */
    function getContextMenu(id) {
        return contextMenuMap[id];
    }

    /**
    * Removes the attached event listeners from the corresponding object.
    * @param {ManuItem} menuItem
    */
    function removeMenuItemEventListeners(menuItem) {
        menuItem._command
            .off("enabledStateChange", menuItem._enabledChanged)
            .off("checkedStateChange", menuItem._checkedChanged)
            .off("nameChange", menuItem._nameChanged)
            .off("keyBindingAdded", menuItem._keyBindingAdded)
            .off("keyBindingRemoved", menuItem._keyBindingRemoved);
    }

    /**
     * Check whether a ContextMenu exists for the given id.
     * @param {string} id
     * @return {boolean}
     */
    function _isContextMenu(id) {
        return !!getContextMenu(id);
    }

    function _isHTMLMenu(id) {
        return (!brackets.nativeMenus || _isContextMenu(id));
    }

    /**
     * Retrieves the MenuItem object for the corresponding id.
     * @param {string} id
     * @return {MenuItem}
     */
    function getMenuItem(id) {
        return menuItemMap[id];
    }

    function _getHTMLMenu(id) {
        return $("#" + StringUtils.jQueryIdEscape(id)).get(0);
    }

    function _getHTMLMenuItem(id) {
        return $("#" + StringUtils.jQueryIdEscape(id)).get(0);
    }

    function _addKeyBindingToMenuItem($menuItem, key, displayKey) {
        var $shortcut = $menuItem.find(".menu-shortcut");

        if ($shortcut.length === 0) {
            $shortcut = $("<span class='menu-shortcut' />");
            $menuItem.append($shortcut);
        }

        $shortcut.data("key", key);
        $shortcut.text(KeyBindingManager.formatKeyDescriptor(displayKey));
    }

    function _addExistingKeyBinding(menuItem) {
        var bindings = KeyBindingManager.getKeyBindings(menuItem.getCommand().getID()),
            binding = null;

        if (bindings.length > 0) {
            // add the latest key binding
            binding = bindings[bindings.length - 1];
            _addKeyBindingToMenuItem($(_getHTMLMenuItem(menuItem.id)), binding.key, binding.displayKey);
        }

        return binding;
    }

    var _menuDividerIDCount = 1;
    function _getNextMenuItemDividerID() {
        return "brackets-menuDivider-" + _menuDividerIDCount++;
    }

    // Help function for inserting elements into a list
    function _insertInList($list, $element, position, $relativeElement) {
        // Determine where to insert. Default is LAST.
        var inserted = false;
        if (position) {

            // Adjust relative position for menu section positions since $relativeElement
            // has already been resolved by _getRelativeMenuItem() to a menuItem
            if (position === FIRST_IN_SECTION) {
                position = BEFORE;
            } else if (position === LAST_IN_SECTION) {
                position = AFTER;
            }

            if (position === FIRST) {
                $list.prepend($element);
                inserted = true;
            } else if ($relativeElement && $relativeElement.length > 0) {
                if (position === AFTER) {
                    $relativeElement.after($element);
                    inserted = true;
                } else if (position === BEFORE) {
                    $relativeElement.before($element);
                    inserted = true;
                }
            }
        }

        // Default to LAST
        if (!inserted) {
            $list.append($element);
        }
    }

    /**
     * MenuItem represents a single menu item that executes a Command or a menu divider. MenuItems
     * may have a sub-menu. A MenuItem may correspond to an HTML-based
     * menu item or a native menu item if Brackets is running in a native application shell
     *
     * Since MenuItems may have a native implementation clients should create MenuItems through
     * addMenuItem() and should NOT construct a MenuItem object directly.
     * Clients should also not access HTML content of a menu directly and instead use
     * the MenuItem API to query and modify menus items.
     *
     * MenuItems are views on to Command objects so modify the underlying Command to modify the
     * name, enabled, and checked state of a MenuItem. The MenuItem will update automatically
     *
     * @constructor
     * @private
     *
     * @param {string} id
     * @param {string|Command} command - the Command this MenuItem will reflect.
     *                                   Use DIVIDER to specify a menu divider
     */
    function MenuItem(id, command) {
        this.id = id;
        this.isDivider = (command === DIVIDER);
        this.isNative = false;

        if (!this.isDivider && command !== SUBMENU) {
            // Bind event handlers
            this._enabledChanged = this._enabledChanged.bind(this);
            this._checkedChanged = this._checkedChanged.bind(this);
            this._nameChanged = this._nameChanged.bind(this);
            this._keyBindingAdded = this._keyBindingAdded.bind(this);
            this._keyBindingRemoved = this._keyBindingRemoved.bind(this);

            this._command = command;
            this._command
                .on("enabledStateChange", this._enabledChanged)
                .on("checkedStateChange", this._checkedChanged)
                .on("nameChange", this._nameChanged)
                .on("keyBindingAdded", this._keyBindingAdded)
                .on("keyBindingRemoved", this._keyBindingRemoved);
        }
    }

    /**
     * Menu represents a top-level menu in the menu bar. A Menu may correspond to an HTML-based
     * menu or a native menu if Brackets is running in a native application shell.
     *
     * Since menus may have a native implementation clients should create Menus through
     * addMenu() and should NOT construct a Menu object directly.
     * Clients should also not access HTML content of a menu directly and instead use
     * the Menu API to query and modify menus.
     *
     * @constructor
     * @private
     *
     * @param {string} id
     */
    function Menu(id) {
        this.id = id;
    }

    Menu.prototype._getMenuItemId = function (commandId) {
        return (this.id + "-" + commandId);
    };

    /**
     * Determine MenuItem in this Menu, that has the specified command
     *
     * @param {Command} command - the command to search for.
     * @return {?HTMLLIElement} menu item list element
     */
    Menu.prototype._getMenuItemForCommand = function (command) {
        if (!command) {
            return null;
        }
        var foundMenuItem = menuItemMap[this._getMenuItemId(command.getID())];
        if (!foundMenuItem) {
            return null;
        }
        return $(_getHTMLMenuItem(foundMenuItem.id)).closest("li");
    };

    /**
     * Determine relative MenuItem
     *
     * @param {?string} relativeID - id of command (future: sub-menu).
     * @param {?string} position - only needed when relativeID is a MenuSection
     * @return {?HTMLLIElement} menu item list element
     */
    Menu.prototype._getRelativeMenuItem = function (relativeID, position) {
        var $relativeElement;

        if (relativeID) {
            if (position === FIRST_IN_SECTION || position === LAST_IN_SECTION) {
                if (!relativeID.hasOwnProperty("sectionMarker")) {
                    console.error("Bad Parameter in _getRelativeMenuItem(): relativeID must be a MenuSection when position refers to a menu section");
                    return null;
                }

                // Determine the $relativeElement by traversing the sibling list and
                // stop at the first divider found
                // TODO: simplify using nextUntil()/prevUntil()
                var $sectionMarker = this._getMenuItemForCommand(CommandManager.get(relativeID.sectionMarker));
                if (!$sectionMarker) {
                    console.error("_getRelativeMenuItem(): MenuSection " + relativeID.sectionMarker +
                                  " not found in Menu " + this.id);
                    return null;
                }
                var $listElem = $sectionMarker;
                $relativeElement = $listElem;
                while (true) {
                    $listElem = (position === FIRST_IN_SECTION ? $listElem.prev() : $listElem.next());
                    if ($listElem.length === 0) {
                        break;
                    } else if ($listElem.find(".divider").length > 0) {
                        break;
                    } else {
                        $relativeElement = $listElem;
                    }
                }

            } else {
                if (relativeID.hasOwnProperty("sectionMarker")) {
                    console.error("Bad Parameter in _getRelativeMenuItem(): if relativeID is a MenuSection, position must be FIRST_IN_SECTION or LAST_IN_SECTION");
                    return null;
                }

                // handle FIRST, LAST, BEFORE, & AFTER
                var command = CommandManager.get(relativeID);
                if (command) {
                    // Lookup Command for this Command id
                    // Find MenuItem that has this command
                    $relativeElement = this._getMenuItemForCommand(command);
                }
                if (!$relativeElement) {
                    console.error("_getRelativeMenuItem(): MenuItem with Command id " + relativeID +
                                  " not found in Menu " + this.id);
                    return null;
                }
            }

            return $relativeElement;

        } else if (position && position !== FIRST && position !== LAST) {
            console.error("Bad Parameter in _getRelativeMenuItem(): relative position specified with no relativeID");
            return null;
        }

        return $relativeElement;
    };

    /**
     * Removes the specified menu item from this Menu. Key bindings are unaffected; use KeyBindingManager
     * directly to remove key bindings if desired.
     *
     * @param {!string | Command} command - command the menu would execute if we weren't deleting it.
     */
    Menu.prototype.removeMenuItem = function (command) {
        var menuItemID,
            commandID;

        if (!command) {
            console.error("removeMenuItem(): missing required parameters: command");
            return;
        }

        if (typeof (command) === "string") {
            var commandObj = CommandManager.get(command);
            if (!commandObj) {
                console.error("removeMenuItem(): command not found: " + command);
                return;
            }
            commandID = command;
        } else {
            commandID = command.getID();
        }
        menuItemID = this._getMenuItemId(commandID);

        var menuItem = getMenuItem(menuItemID);
        removeMenuItemEventListeners(menuItem);

        if (_isHTMLMenu(this.id)) {
            // Targeting parent to get the menu item <a> and the <li> that contains it
            $(_getHTMLMenuItem(menuItemID)).parent().remove();
        } else {
            brackets.app.removeMenuItem(commandID, function (err) {
                if (err) {
                    console.error("removeMenuItem() -- command not found: " + commandID + " (error: " + err + ")");
                }
            });
        }

        delete menuItemMap[menuItemID];
    };

    /**
     * Removes the specified menu divider from this Menu.
     *
     * @param {!string} menuItemID - the menu item id of the divider to remove.
     */
    Menu.prototype.removeMenuDivider = function (menuItemID) {
        var menuItem,
            $HTMLMenuItem;

        if (!menuItemID) {
            console.error("removeMenuDivider(): missing required parameters: menuItemID");
            return;
        }

        menuItem = getMenuItem(menuItemID);

        if (!menuItem) {
            console.error("removeMenuDivider(): parameter menuItemID: %s is not a valid menu item id", menuItemID);
            return;
        }

        if (!menuItem.isDivider) {
            console.error("removeMenuDivider(): parameter menuItemID: %s is not a menu divider", menuItemID);
            return;
        }

        if (_isHTMLMenu(this.id)) {
            // Targeting parent to get the menu divider <hr> and the <li> that contains it
            $HTMLMenuItem = $(_getHTMLMenuItem(menuItemID)).parent();
            if ($HTMLMenuItem) {
                $HTMLMenuItem.remove();
            } else {
                console.error("removeMenuDivider(): HTML menu divider not found: %s", menuItemID);
                return;
            }
        } else {
            brackets.app.removeMenuItem(menuItem.dividerId, function (err) {
                if (err) {
                    console.error("removeMenuDivider() -- divider not found: %s (error: %s)", menuItemID, err);
                }
            });
        }

        if (!menuItemMap[menuItemID]) {
            console.error("removeMenuDivider(): menu divider not found in menuItemMap: %s", menuItemID);
            return;
        }

        delete menuItemMap[menuItemID];
    };

    /**
     * Adds a new menu item with the specified id and display text. The insertion position is
     * specified via the relativeID and position arguments which describe a position
     * relative to another MenuItem or MenuGroup. It is preferred that plug-ins
     * insert new  MenuItems relative to a menu section rather than a specific
     * MenuItem (see Menu Section Constants).
     *
     * TODO: Sub-menus are not yet supported, but when they are implemented this API will
     * allow adding new MenuItems to sub-menus as well.
     *
     * Note, keyBindings are bound to Command objects not MenuItems. The provided keyBindings
     *      will be bound to the supplied Command object rather than the MenuItem.
     *
     * @param {!string | Command} command - the command the menu will execute.
     *      Pass Menus.DIVIDER for a menu divider, or just call addMenuDivider() instead.
     * @param {?string | Array.<{key: string, platform: string}>}  keyBindings - register one
     *      one or more key bindings to associate with the supplied command.
     * @param {?string} position - constant defining the position of new MenuItem relative to
     *      other MenuItems. Values:
     *          - With no relativeID, use Menus.FIRST or LAST (default is LAST)
     *          - Relative to a command id, use BEFORE or AFTER (required)
     *          - Relative to a MenuSection, use FIRST_IN_SECTION or LAST_IN_SECTION (required)
     * @param {?string} relativeID - command id OR one of the MenuSection.* constants. Required
     *      for all position constants except FIRST and LAST.
     *
     * @return {MenuItem} the newly created MenuItem
     */
    Menu.prototype.addMenuItem = function (command, keyBindings, position, relativeID) {
        var menuID = this.id,
            id,
            $menuItem,
            menuItem,
            name,
            commandID;

        if (!command) {
            console.error("addMenuItem(): missing required parameters: command");
            return null;
        }

        if (typeof (command) === "string") {
            if (command === DIVIDER) {
                name = DIVIDER;
                commandID = _getNextMenuItemDividerID();
            } else {
                commandID = command;
                command = CommandManager.get(commandID);
                if (!command) {
                    console.error("addMenuItem(): commandID not found: " + commandID);
                    return null;
                }
                name = command.getName();
            }
        } else {
            commandID = command.getID();
            name = command.getName();
        }

        // Internal id is the a composite of the parent menu id and the command id.
        id = this._getMenuItemId(commandID);

        if (menuItemMap[id]) {
            console.log("MenuItem added with same id of existing MenuItem: " + id);
            return null;
        }

        // create MenuItem
        menuItem = new MenuItem(id, command);
        menuItemMap[id] = menuItem;

        // create MenuItem DOM
        if (_isHTMLMenu(this.id)) {
            if (name === DIVIDER) {
                $menuItem = $("<li><hr class='divider' id='" + id + "' /></li>");
            } else {
                // Create the HTML Menu
                $menuItem = $("<li><a href='#' id='" + id + "'> <span class='menu-name'></span></a></li>");

                $menuItem.on("click", function () {
                    menuItem._command.execute();
                });

                var self = this;
                $menuItem.on("mouseenter", function () {
                    self.closeSubMenu();
                });
            }

            // Insert menu item
            var $relativeElement = this._getRelativeMenuItem(relativeID, position);
            _insertInList($("li#" + StringUtils.jQueryIdEscape(this.id) + " > ul.dropdown-menu"),
                          $menuItem, position, $relativeElement);
        } else {
            var bindings = KeyBindingManager.getKeyBindings(commandID),
                binding,
                bindingStr = "",
                displayStr = "";

            if (bindings && bindings.length > 0) {
                binding = bindings[bindings.length - 1];
                bindingStr = binding.displayKey || binding.key;
            }

            if (bindingStr.length > 0) {
                displayStr = KeyBindingManager.formatKeyDescriptor(bindingStr);
            }

            if (position === FIRST_IN_SECTION || position === LAST_IN_SECTION) {
                if (!relativeID.hasOwnProperty("sectionMarker")) {
                    console.error("Bad Parameter in _getRelativeMenuItem(): relativeID must be a MenuSection when position refers to a menu section");
                    return null;
                }

                // For sections, pass in the marker for that section.
                relativeID = relativeID.sectionMarker;
            }

            brackets.app.addMenuItem(this.id, name, commandID, bindingStr, displayStr, position, relativeID, function (err) {
                switch (err) {
                case NO_ERROR:
                    break;
                case ERR_INVALID_PARAMS:
                    console.error("addMenuItem(): Invalid Parameters when adding the command " + commandID);
                    break;
                case ERR_NOT_FOUND:
                    console.error("_getRelativeMenuItem(): MenuItem with Command id " + relativeID + " not found in the Menu " + menuID);
                    break;
                default:
                    console.error("addMenuItem(); Unknown Error (" + err + ") when adding the command " + commandID);
                }
            });
            menuItem.isNative = true;
        }

        // Initialize MenuItem state
        if (menuItem.isDivider) {
            menuItem.dividerId = commandID;
        } else {
            if (keyBindings) {
                // Add key bindings. The MenuItem listens to the Command object to update MenuItem DOM with shortcuts.
                if (!Array.isArray(keyBindings)) {
                    keyBindings = [keyBindings];
                }
            }

            // Note that keyBindings passed during MenuItem creation take precedent over any existing key bindings
            KeyBindingManager.addBinding(commandID, keyBindings);

            // Look for existing key bindings
            _addExistingKeyBinding(menuItem);

            menuItem._checkedChanged();
            menuItem._enabledChanged();
            menuItem._nameChanged();
        }

        return menuItem;
    };

    /**
     * Inserts divider item in menu.
     * @param {?string} position - constant defining the position of new the divider relative
     *      to other MenuItems. Default is LAST.  (see Insertion position constants).
     * @param {?string} relativeID - id of menuItem, sub-menu, or menu section that the new
     *      divider will be positioned relative to. Required for all position constants
     *      except FIRST and LAST
     *
     * @return {MenuItem} the newly created divider
     */
    Menu.prototype.addMenuDivider = function (position, relativeID) {
        return this.addMenuItem(DIVIDER, "", position, relativeID);
    };

    /**
     * NOT IMPLEMENTED
     * Alternative JSON based API to addMenuItem()
     *
     * All properties are required unless noted as optional.
     *
     * @param { Array.<{
     *              id:         string,
     *              command:    string | Command,
     *              ?bindings:   string | Array.<{key: string, platform: string}>,
     *          }>} jsonStr
     *        }
     * @param {?string} position - constant defining the position of new the MenuItem relative
     *      to other MenuItems. Default is LAST.  (see Insertion position constants).
     * @param {?string} relativeID - id of menuItem, sub-menu, or menu section that the new
     *      menuItem will be positioned relative to. Required when position is
     *      AFTER or BEFORE, ignored when position is FIRST or LAST.
     *
     * @return {MenuItem} the newly created MenuItem
     */
    // Menu.prototype.createMenuItemsFromJSON = function (jsonStr, position, relativeID) {
    //     NOT IMPLEMENTED
    // };


    /**
     * NOT IMPLEMENTED
     * @param {!string} text displayed in menu item
     * @param {!string} id
     * @param {?string} position - constant defining the position of new the MenuItem relative
     *      to other MenuItems. Default is LAST.  (see Insertion position constants)
     * @param {?string} relativeID - id of menuItem, sub-menu, or menu section that the new
     *      menuItem will be positioned relative to. Required when position is
     *      AFTER or BEFORE, ignored when position is FIRST or LAST.
     *
     * @return {MenuItem} newly created menuItem for sub-menu
     */
    // MenuItem.prototype.createSubMenu = function (text, id, position, relativeID) {
    //     NOT IMPLEMENTED
    // };

    /**
     *
     * Creates a new submenu and a menuItem and adds the menuItem of the submenu
     * to the menu and returns the submenu.
     *
     * A submenu will have the same structure of a menu with a additional field
     * parentMenuItem which has the reference of the submenu's parent menuItem.

     * A submenu will raise the following events:
     * - beforeSubMenuOpen
     * - beforeSubMenuClose
     *
     * Note, This function will create only a context submenu.
     *
     * TODO: Make this function work for Menus
     *
     *
     * @param {!string} name displayed in menu item of the submenu
     * @param {!string} id
     * @param {?string} position - constant defining the position of new MenuItem of the submenu relative to
     *      other MenuItems. Values:
     *          - With no relativeID, use Menus.FIRST or LAST (default is LAST)
     *          - Relative to a command id, use BEFORE or AFTER (required)
     *          - Relative to a MenuSection, use FIRST_IN_SECTION or LAST_IN_SECTION (required)
     * @param {?string} relativeID - command id OR one of the MenuSection.* constants. Required
     *      for all position constants except FIRST and LAST.
     *
     * @return {Menu} the newly created submenu
     */
    Menu.prototype.addSubMenu = function (name, id, position, relativeID) {

        if (!name || !id) {
            console.error("addSubMenu(): missing required parameters: name and id");
            return null;
        }

        // Guard against duplicate context menu ids
        if (contextMenuMap[id]) {
            console.log("Context menu added with id of existing Context Menu: " + id);
            return null;
        }

        var menu = new ContextMenu(id);
        contextMenuMap[id] = menu;

        var menuItemID = this.id + "-" + id;

        if (menuItemMap[menuItemID]) {
            console.log("MenuItem added with same id of existing MenuItem: " + id);
            return null;
        }

        // create MenuItem
        var menuItem = new MenuItem(menuItemID, SUBMENU);
        menuItemMap[menuItemID] = menuItem;

        menu.parentMenuItem = menuItem;

        // create MenuItem DOM
        if (_isHTMLMenu(this.id)) {
            // Create the HTML MenuItem
            var $menuItem = $("<li><a href='#' id='" + menuItemID + "'> "   +
                             "<span class='menu-name'>" + name + "</span>" +
                             "<span style='float: right'>&rtrif;</span>"   +
                             "</a></li>");

            var self = this;
            $menuItem.on("mouseenter", function(e) {
                if (self.openSubMenu && self.openSubMenu.id === menu.id) {
                    return;
                }
                self.closeSubMenu();
                self.openSubMenu = menu;
                menu.open();
            });

            // Insert menu item
            var $relativeElement = this._getRelativeMenuItem(relativeID, position);
            _insertInList($("li#" + StringUtils.jQueryIdEscape(this.id) + " > ul.dropdown-menu"),
            $menuItem, position, $relativeElement);
        } else {
            // TODO: add submenus for native menus
        }
        return menu;
    };


    /**
     * Removes the specified submenu from this Menu.
     *
     * Note, this function will only remove context submenus
     *
     * TODO: Make this function work for Menus
     *
     * @param {!string} subMenuID - the menu id of the submenu to remove.
     */
    Menu.prototype.removeSubMenu = function (subMenuID) {
        var subMenu,
            parentMenuItem,
            commandID = "";

        if (!subMenuID) {
            console.error("removeSubMenu(): missing required parameters: subMenuID");
            return;
        }

        subMenu = getContextMenu(subMenuID);

        if (!subMenu || !subMenu.parentMenuItem) {
            console.error("removeSubMenu(): parameter subMenuID: %s is not a valid submenu id", subMenuID);
            return;
        }

        parentMenuItem = subMenu.parentMenuItem;


        if (!menuItemMap[parentMenuItem.id]) {
            console.error("removeSubMenu(): parent menuItem not found in menuItemMap: %s", parentMenuItem.id);
            return;
        }

        // Remove all of the menu items in the submenu
        _.forEach(menuItemMap, function (value, key) {
            if (_.startsWith(key, subMenuID)) {
                if (value.isDivider) {
                    subMenu.removeMenuDivider(key);
                } else {
                    commandID = value.getCommand();
                    subMenu.removeMenuItem(commandID);
                }
            }
        });

        if (_isHTMLMenu(this.id)) {
            $(_getHTMLMenuItem(parentMenuItem.id)).parent().remove(); // remove the menu item
            $(_getHTMLMenu(subMenuID)).remove(); // remove the menu
        } else {
            // TODO: remove submenus for native menus
        }


        delete menuItemMap[parentMenuItem.id];
        delete contextMenuMap[subMenuID];
    };

    /**
     * Closes the submenu if the menu has a submenu open.
     */
    Menu.prototype.closeSubMenu = function() {
        if (this.openSubMenu) {
            this.openSubMenu.close();
            this.openSubMenu = null;
        }
    };
    /**
     * Gets the Command associated with a MenuItem
     * @return {Command}
     */
    MenuItem.prototype.getCommand = function () {
        return this._command;
    };

    /**
     * NOT IMPLEMENTED
     * Returns the parent MenuItem if the menu item is a sub-menu, returns null otherwise.
     * @return {MenuItem}
     */
    // MenuItem.prototype.getParentMenuItem = function () {
    //     NOT IMPLEMENTED;
    // };

    /**
     * Returns the parent Menu for this MenuItem
     * @return {Menu}
     */
    MenuItem.prototype.getParentMenu = function () {
        var parent = $(_getHTMLMenuItem(this.id)).parents(".dropdown").get(0);
        if (!parent) {
            return null;
        }

        return getMenu(parent.id);
    };

    /**
     * Synchronizes MenuItem checked state with underlying Command checked state
     */
    MenuItem.prototype._checkedChanged = function () {
        var checked = !!this._command.getChecked();
        if (this.isNative) {
            var enabled = !!this._command.getEnabled();
            brackets.app.setMenuItemState(this._command.getID(), enabled, checked, function (err) {
                if (err) {
                    console.log("Error setting menu item state: " + err);
                }
            });
        } else {
            ViewUtils.toggleClass($(_getHTMLMenuItem(this.id)), "checked", checked);
        }
    };

    /**
     * Synchronizes MenuItem enabled state with underlying Command enabled state
     */
    MenuItem.prototype._enabledChanged = function () {
        if (this.isNative) {
            var enabled = !!this._command.getEnabled();
            var checked = !!this._command.getChecked();
            brackets.app.setMenuItemState(this._command.getID(), enabled, checked, function (err) {
                if (err) {
                    console.log("Error setting menu item state: " + err);
                }
            });
        } else {
            ViewUtils.toggleClass($(_getHTMLMenuItem(this.id)), "disabled", !this._command.getEnabled());
        }
    };

    /**
     * Synchronizes MenuItem name with underlying Command name
     */
    MenuItem.prototype._nameChanged = function () {
        if (this.isNative) {
            brackets.app.setMenuTitle(this._command.getID(), this._command.getName(), function (err) {
                if (err) {
                    console.log("Error setting menu title: " + err);
                }
            });
        } else {
            $(_getHTMLMenuItem(this.id)).find(".menu-name").text(this._command.getName());
        }
    };

    /**
     * @private
     * Updates MenuItem DOM with a keyboard shortcut label
     */
    MenuItem.prototype._keyBindingAdded = function (event, keyBinding) {
        if (this.isNative) {
            var shortcutKey = keyBinding.displayKey || keyBinding.key;
            brackets.app.setMenuItemShortcut(this._command.getID(), shortcutKey, KeyBindingManager.formatKeyDescriptor(shortcutKey), function (err) {
                if (err) {
                    console.error("Error setting menu item shortcut: " + err);
                }
            });
        } else {
            _addKeyBindingToMenuItem($(_getHTMLMenuItem(this.id)), keyBinding.key, keyBinding.displayKey);
        }
    };

    /**
     * @private
     * Updates MenuItem DOM to remove keyboard shortcut label
     */
    MenuItem.prototype._keyBindingRemoved = function (event, keyBinding) {
        if (this.isNative) {
            brackets.app.setMenuItemShortcut(this._command.getID(), "", "", function (err) {
                if (err) {
                    console.error("Error setting menu item shortcut: " + err);
                }
            });
        } else {
            var $shortcut = $(_getHTMLMenuItem(this.id)).find(".menu-shortcut");

            if ($shortcut.length > 0 && $shortcut.data("key") === keyBinding.key) {
                // check for any other bindings
                if (_addExistingKeyBinding(this) === null) {
                    $shortcut.empty();
                }
            }
        }
    };

    /**
     * Closes all menus that are open
     */
    function closeAll() {
        $(".dropdown").removeClass("open");
    }

    /**
     * Adds a top-level menu to the application menu bar which may be native or HTML-based.
     *
     * @param {!string} name - display text for menu
     * @param {!string} id - unique identifier for a menu.
     *      Core Menus in Brackets use a simple  title as an id, for example "file-menu".
     *      Extensions should use the following format: "author.myextension.mymenuname".
     * @param {?string} position - constant defining the position of new the Menu relative
     *  to other Menus. Default is LAST (see Insertion position constants).
     *
     * @param {?string} relativeID - id of Menu the new Menu will be positioned relative to. Required
     *      when position is AFTER or BEFORE, ignored when position is FIRST or LAST
     *
     * @return {?Menu} the newly created Menu
     */
    function addMenu(name, id, position, relativeID) {
        name = _.escape(name);
        var $menubar = $("#titlebar .nav"),
            menu;

        if (!name || !id) {
            console.error("call to addMenu() is missing required parameters");
            return null;
        }

        // Guard against duplicate menu ids
        if (menuMap[id]) {
            console.log("Menu added with same name and id of existing Menu: " + id);
            return null;
        }

        menu = new Menu(id);
        menuMap[id] = menu;

        if (!_isHTMLMenu(id)) {
            brackets.app.addMenu(name, id, position, relativeID, function (err) {
                switch (err) {
                case NO_ERROR:
                    // Make sure name is up to date
                    brackets.app.setMenuTitle(id, name, function (err) {
                        if (err) {
                            console.error("setMenuTitle() -- error: " + err);
                        }
                    });
                    break;
                case ERR_UNKNOWN:
                    console.error("addMenu(): Unknown Error when adding the menu " + id);
                    break;
                case ERR_INVALID_PARAMS:
                    console.error("addMenu(): Invalid Parameters when adding the menu " + id);
                    break;
                case ERR_NOT_FOUND:
                    console.error("addMenu(): Menu with command " + relativeID + " could not be found when adding the menu " + id);
                    break;
                default:
                    console.error("addMenu(): Unknown Error (" + err + ") when adding the menu " + id);
                }
            });
            return menu;
        }

        var $toggle = $("<a href='#' class='dropdown-toggle' data-toggle='dropdown'>" + name + "</a>"),
            $popUp = $("<ul class='dropdown-menu'></ul>"),
            $newMenu = $("<li class='dropdown' id='" + id + "'></li>").append($toggle).append($popUp);

        // Insert menu
        var $relativeElement = relativeID && $(_getHTMLMenu(relativeID));
        _insertInList($menubar, $newMenu, position, $relativeElement);

        // Install ESC key handling
        PopUpManager.addPopUp($popUp, closeAll, false);

        // todo error handling

        return menu;
    }

    /**
     * Removes a top-level menu from the application menu bar which may be native or HTML-based.
     *
     * @param {!string} id - unique identifier for a menu.
     *      Core Menus in Brackets use a simple title as an id, for example "file-menu".
     *      Extensions should use the following format: "author.myextension.mymenuname".
     */
    function removeMenu(id) {
        var menu,
            commandID = "";

        if (!id) {
            console.error("removeMenu(): missing required parameter: id");
            return;
        }

        if (!menuMap[id]) {
            console.error("removeMenu(): menu id not found: %s", id);
            return;
        }

        // Remove all of the menu items in the menu
        menu = getMenu(id);

        _.forEach(menuItemMap, function (value, key) {
            if (_.startsWith(key, id)) {
                if (value.isDivider) {
                    menu.removeMenuDivider(key);
                } else {
                    commandID = value.getCommand();
                    menu.removeMenuItem(commandID);
                }
            }
        });

        if (_isHTMLMenu(id)) {
            $(_getHTMLMenu(id)).remove();
        } else {
            brackets.app.removeMenu(id, function (err) {
                if (err) {
                    console.error("removeMenu() -- id not found: " + id + " (error: " + err + ")");
                }
            });
        }

        delete menuMap[id];
    }

    /**
     * Represents a context menu that can open at a specific location in the UI.
     *
     * Clients should not create this object directly and should instead use registerContextMenu()
     * to create new ContextMenu objects.
     *
     * Context menus in brackets may be HTML-based or native so clients should not reach into
     * the HTML and should instead manipulate ContextMenus through the API.
     *
     * Events:
     * - beforeContextMenuOpen
     * - beforeContextMenuClose
     *
     * @constructor
     * @extends {Menu}
     */
    function ContextMenu(id) {
        Menu.apply(this, arguments);

        var $newMenu = $("<li class='dropdown context-menu' id='" + StringUtils.jQueryIdEscape(id) + "'></li>"),
            $popUp = $("<ul class='dropdown-menu'></ul>"),
            $toggle = $("<a href='#' class='dropdown-toggle' data-toggle='dropdown'></a>").hide();

        // assemble the menu fragments
        $newMenu.append($toggle).append($popUp);

        // insert into DOM
        $("#context-menu-bar > ul").append($newMenu);

        var self = this;
        PopUpManager.addPopUp($popUp,
            function () {
                self.close();
            },
            false);

        // Listen to ContextMenu's beforeContextMenuOpen event to first close other popups
        PopUpManager.listenToContextMenu(this);
    }
    ContextMenu.prototype = Object.create(Menu.prototype);
    ContextMenu.prototype.constructor = ContextMenu;
    ContextMenu.prototype.parentClass = Menu.prototype;
    EventDispatcher.makeEventDispatcher(ContextMenu.prototype);


    /**
     * Displays the ContextMenu at the specified location and dispatches the
     * "beforeContextMenuOpen" event or "beforeSubMenuOpen" event (for submenus).
     * The menu location may be adjusted to prevent clipping by the browser window.
     * All other menus and ContextMenus will be closed before a new menu
     * will be closed before a new menu is shown (if the new menu is not
     * a submenu).
     *
     * In case of submenus, the parentMenu of the submenu will not be closed when the
     * sub menu is open.
     *
     * @param {MouseEvent | {pageX:number, pageY:number}} mouseOrLocation - pass a MouseEvent
     *      to display the menu near the mouse or pass in an object with page x/y coordinates
     *      for a specific location.This paramter is not used for submenus. Submenus are always
     *      displayed at a position relative to the parent menu.
     */
    ContextMenu.prototype.open = function (mouseOrLocation) {

        if (!this.parentMenuItem &&
           (!mouseOrLocation || !mouseOrLocation.hasOwnProperty("pageX") || !mouseOrLocation.hasOwnProperty("pageY"))) {
            console.error("ContextMenu open(): missing required parameter");
            return;
        }

        var $window = $(window),
            escapedId = StringUtils.jQueryIdEscape(this.id),
            $menuAnchor = $("#" + escapedId),
            $menuWindow = $("#" + escapedId + " > ul"),
            posTop,
            posLeft;

        // only show context menu if it has menu items
        if ($menuWindow.children().length <= 0) {
            return;
        }


        // adjust positioning so menu is not clipped off bottom or right
        if (this.parentMenuItem) { // If context menu is a submenu

            this.trigger("beforeSubMenuOpen");

            var $parentMenuItem = $(_getHTMLMenuItem(this.parentMenuItem.id));

            posTop = $parentMenuItem.offset().top;
            posLeft = $parentMenuItem.offset().left + $parentMenuItem.outerWidth();

            var elementRect = {
                top:    posTop,
                left:   posLeft,
                height: $menuWindow.height() + 25,
                width:  $menuWindow.width()
            },
            clip = ViewUtils.getElementClipSize($window, elementRect);

            if (clip.bottom > 0) {
                posTop = Math.max(0, posTop + $parentMenuItem.height() - $menuWindow.height());
            }

            posTop -= 30;   // shift top for hidden parent element
            posLeft += 3;

            if (clip.right > 0) {
                posLeft = Math.max(0, posLeft - $parentMenuItem.outerWidth() - $menuWindow.outerWidth());
            }
        } else {
            this.trigger("beforeContextMenuOpen");

            // close all other dropdowns
            closeAll();

            posTop  = mouseOrLocation.pageY;
            posLeft = mouseOrLocation.pageX;

            var elementRect = {
                top:    posTop,
                left:   posLeft,
                height: $menuWindow.height() + 25,
                width:  $menuWindow.width()
            },
            clip = ViewUtils.getElementClipSize($window, elementRect);

            if (clip.bottom > 0) {
                posTop = Math.max(0, posTop - clip.bottom);
            }
            posTop -= 30;   // shift top for hidden parent element
            posLeft += 5;


            if (clip.right > 0) {
                posLeft = Math.max(0, posLeft - clip.right);
            }
        }

        // open the context menu at final location
        $menuAnchor.addClass("open")
                   .css({"left": posLeft, "top": posTop});
    };


    /**
     * Closes the context menu.
     */
    ContextMenu.prototype.close = function () {
        if (this.parentMenuItem) {
            this.trigger("beforeSubMenuClose");
        } else {
            this.trigger("beforeContextMenuClose");
        }
        this.closeSubMenu();
        $("#" + StringUtils.jQueryIdEscape(this.id)).removeClass("open");
    };

    /**
     * Detect if current context menu is already open
     */
    ContextMenu.prototype.isOpen = function () {
        return $("#" + StringUtils.jQueryIdEscape(this.id)).hasClass("open");
    };


    /**
     * Associate a context menu to a DOM element.
     * This static function take care of registering event handlers for the click event
     * listener and passing the right "position" object to the Context#open method
     */
    ContextMenu.assignContextMenuToSelector = function (selector, cmenu) {
        $(selector).on("click", function (e) {
            var buttonOffset,
                buttonHeight;

            e.stopPropagation();

            if (cmenu.isOpen()) {
                cmenu.close();
            } else {
                buttonOffset = $(this).offset();
                buttonHeight = $(this).outerHeight();
                cmenu.open({
                    pageX: buttonOffset.left,
                    pageY: buttonOffset.top + buttonHeight
                });
            }
        });
    };


    /**
     * Registers new context menu with Brackets.

     * Extensions should generally use the predefined context menus built into Brackets. Use this
     * API to add a new context menu to UI that is specific to an extension.
     *
     * After registering  a new context menu clients should:
     *      - use addMenuItem() to add items to the context menu
     *      - call open() to show the context menu.
     *      For example:
     *      $("#my_ID").contextmenu(function (e) {
     *          if (e.which === 3) {
     *              my_cmenu.open(e);
     *          }
     *      });
     *
     * To make menu items be contextual to things like selection, listen for the "beforeContextMenuOpen"
     * to make changes to Command objects before the context menu is shown. MenuItems are views of
     * Commands, which control a MenuItem's name, enabled state, and checked state.
     *
     * @param {string} id - unique identifier for context menu.
     *      Core context menus in Brackets use a simple title as an id.
     *      Extensions should use the following format: "author.myextension.mycontextmenu name"
     * @return {?ContextMenu} the newly created context menu
     */
    function registerContextMenu(id) {
        if (!id) {
            console.error("call to registerContextMenu() is missing required parameters");
            return null;
        }

        // Guard against duplicate menu ids
        if (contextMenuMap[id]) {
            console.log("Context Menu added with same name and id of existing Context Menu: " + id);
            return null;
        }

        var cmenu = new ContextMenu(id);
        contextMenuMap[id] = cmenu;
        return cmenu;
    }

    // Deprecated menu ids
    DeprecationWarning.deprecateConstant(ContextMenuIds, "WORKING_SET_MENU", "WORKING_SET_CONTEXT_MENU");
    DeprecationWarning.deprecateConstant(ContextMenuIds, "WORKING_SET_SETTINGS_MENU", "WORKING_SET_CONFIG_MENU");

    // Define public API
    exports.AppMenuBar = AppMenuBar;
    exports.ContextMenuIds = ContextMenuIds;
    exports.MenuSection = MenuSection;
    exports.BEFORE = BEFORE;
    exports.AFTER = AFTER;
    exports.LAST = LAST;
    exports.FIRST = FIRST;
    exports.FIRST_IN_SECTION = FIRST_IN_SECTION;
    exports.LAST_IN_SECTION = LAST_IN_SECTION;
    exports.DIVIDER = DIVIDER;
    exports.getMenu = getMenu;
    exports.getAllMenus = getAllMenus;
    exports.getMenuItem = getMenuItem;
    exports.getContextMenu = getContextMenu;
    exports.addMenu = addMenu;
    exports.removeMenu = removeMenu;
    exports.registerContextMenu = registerContextMenu;
    exports.closeAll = closeAll;
    exports.Menu = Menu;
    exports.MenuItem = MenuItem;
    exports.ContextMenu = ContextMenu;
});