adobe/brackets

View on GitHub
src/widgets/InlineMenu.js

Summary

Maintainability
F
4 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";

    // Load dependent modules
    var KeyBindingManager = require("command/KeyBindingManager"),
        Menus             = require("command/Menus"),
        KeyEvent          = require("utils/KeyEvent"),
        StringUtils       = require("utils/StringUtils"),
        ValidationUtils   = require("utils/ValidationUtils"),
        ViewUtils         = require("utils/ViewUtils"),
        PopUpManager      = require("widgets/PopUpManager"),
        Mustache          = require("thirdparty/mustache/mustache");

    var MenuHTML  = require("text!htmlContent/inline-menu.html");

    /**
     * Displays a popup list of items for a given editor context
     *
     * @constructor
     * @param {Editor} editor
     * @param {string} menuText
     */
    function InlineMenu(editor, menuText) {
        /**
         * The list of items to display
         *
         * @type {Array.<{id: number, name: string>}
         */
        this.items = [];

        /**
         * The selected position in the list; otherwise -1.
         *
         * @type {number}
         */
        this.selectedIndex = -1;


        /**
         * Is the list currently open?
         *
         * @type {boolean}
         */
        this.opened = false;


        /**
         * The editor context
         *
         * @type {Editor}
         */
        this.editor = editor;


        /**
         * The menu selection callback function
         *
         * @type {Function}
         */
        this.handleSelect = null;

        /**
         * The menu closure callback function
         *
         * @type {Function}
         */
        this.handleClose = null;

        /**
         * The menu object
         *
         * @type {jQuery.Object}
         */
        this.$menu =
            $("<li class='dropdown inlinemenu-menu'></li>")
                .append($("<a href='#' class='dropdown-toggle' data-toggle='dropdown'></a>")
                        .hide())
                .append("<ul class='dropdown-menu'>" +
                            "<li class='inlinemenu-header'>" +
                                "<a>" + menuText + "</a>" +
                            "</li>" +
                         "</ul>");

        this._keydownHook = this._keydownHook.bind(this);
    }

    /**
     * Select the item in the menu at the specified index, or remove the
     * selection if index < 0.
     *
     * @private
     * @param {number} index
     */
    InlineMenu.prototype._setSelectedIndex = function (index) {
        var items = this.$menu.find("li.inlinemenu-item");

        // Range check
        index = Math.max(-1, Math.min(index, items.length - 1));

        // Clear old highlight
        if (this.selectedIndex !== -1) {
            $(items[this.selectedIndex]).find("a").removeClass("highlight");
        }

        this.selectedIndex = index;

        // Highlight the new selected item, if necessary
        if (this.selectedIndex !== -1) {
            var $item = $(items[this.selectedIndex]);
            var $view = this.$menu.find("ul.dropdown-menu");

            $item.find("a").addClass("highlight");
            ViewUtils.scrollElementIntoView($view, $item, false);
        }

        // Invoke handleHover callback if any
        if (this.handleHover) {
            this.handleHover(this.items[index].id);
        }
    };

    /**
     * Rebuilds the list items for the menu.
     *
     * @private
     */
    InlineMenu.prototype._buildListView = function (items) {
        var self            = this,
            view            = { items: [] },
            _addItem;

        this.items = items;

        _addItem = function (item) {
            view.items.push({ formattedItem: "<span>" + item.name + "</span>"});
        };

        // clear the list
        this.$menu.find("li.inlinemenu-item").remove();

        // if there are no items then close the list; otherwise add them and
        // set the selection
        if (this.items.length === 0) {
            if (this.handleClose) {
                this.handleClose();
            }
        } else {
            this.items.some(function (item, index) {
                _addItem(item);
            });

            // render the menu list
            var $ul = this.$menu.find("ul.dropdown-menu"),
                $parent = $ul.parent();

            // remove list temporarily to save rendering time
            $ul.remove().append(Mustache.render(MenuHTML, view));

            $ul.children("li.inlinemenu-item").each(function (index, element) {
                var item      = self.items[index],
                    $element    = $(element);

                // store id of item in the element
                $element.data("itemid", item.id);
            });

            // delegate list item events to the top-level ul list element
            $ul.on("click", "li.inlinemenu-item", function (e) {
                // Don't let the click propagate upward (otherwise it will
                // hit the close handler in bootstrap-dropdown).
                e.stopPropagation();
                if (self.handleSelect) {
                    self.handleSelect($(this).data("itemid"));
                }
            });

            $ul.on("mouseover", "li.inlinemenu-item", function (e) {
                e.stopPropagation();
                // _setSelectedIndex sets the selected index and call handle hover
                // callback funtion
                self._setSelectedIndex(self.items.findIndex(function(element) {
                    return element.id === $(e.currentTarget).data("itemid");
                }));
            });

            $parent.append($ul);

            this._setSelectedIndex(0);
        }
    };

    /**
     * Computes top left location for menu so that the menu is not clipped by the window.
     * Also computes the largest available width.
     *
     * @private
     * @return {{left: number, top: number, width: number}}
     */
    InlineMenu.prototype._calcMenuLocation = function () {
        var cursor      = this.editor._codeMirror.cursorCoords(),
            posTop      = cursor.bottom,
            posLeft     = cursor.left,
            textHeight  = this.editor.getTextHeight(),
            $window     = $(window),
            $menuWindow = this.$menu.children("ul"),
            menuHeight  = $menuWindow.outerHeight();

        // TODO Ty: factor out menu repositioning logic so inline menu and Context menus share code
        // adjust positioning so menu is not clipped off bottom or right
        var bottomOverhang = posTop + menuHeight - $window.height();
        if (bottomOverhang > 0) {
            posTop -= (textHeight + 2 + menuHeight);
        }

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

        var menuWidth = $menuWindow.width();
        var availableWidth = menuWidth;
        var rightOverhang = posLeft + menuWidth - $window.width();
        if (rightOverhang > 0) {
            // Right overhang is negative
            posLeft = Math.max(0, posLeft - rightOverhang);
        }

        return {left: posLeft, top: posTop, width: availableWidth};
    };


    /**
     * Check whether Event is one of the keys that we handle or not.
     *
     * @param {KeyBoardEvent|keyBoardEvent.keyCode} keyEvent
     */
    InlineMenu.prototype.isHandlingKeyCode = function (keyCodeOrEvent) {
        var keyCode = typeof keyCodeOrEvent === "object" ? keyCodeOrEvent.keyCode : keyCodeOrEvent;
        var ctrlKey = typeof keyCodeOrEvent === "object" ? keyCodeOrEvent.ctrlKey : false;


        return (keyCode === KeyEvent.DOM_VK_UP || keyCode === KeyEvent.DOM_VK_DOWN ||
            keyCode === KeyEvent.DOM_VK_PAGE_UP || keyCode === KeyEvent.DOM_VK_PAGE_DOWN ||
            keyCode === KeyEvent.DOM_VK_RETURN ||
            keyCode === KeyEvent.DOM_VK_ESCAPE
        );
    };

    /**
     * Convert keydown events into hint list navigation actions.
     *
     * @param {KeyBoardEvent} keyEvent
     */
    InlineMenu.prototype._keydownHook = function (event) {
        var keyCode,
            self = this;

        // positive distance rotates down; negative distance rotates up
        function _rotateSelection(distance) {
            var len = self.items.length,
                pos;

            if (self.selectedIndex < 0) {
                // set the initial selection
                pos = (distance > 0) ? distance - 1 : len - 1;

            } else {
                // adjust current selection
                pos = self.selectedIndex;

                // Don't "rotate" until all items have been shown
                if (distance > 0) {
                    if (pos === (len - 1)) {
                        pos = 0;  // wrap
                    } else {
                        pos = Math.min(pos + distance, len - 1);
                    }
                } else {
                    if (pos === 0) {
                        pos = (len - 1);  // wrap
                    } else {
                        pos = Math.max(pos + distance, 0);
                    }
                }
            }

            self._setSelectedIndex(pos);
        }

        // Calculate the number of items per scroll page.
        function _itemsPerPage() {
            var itemsPerPage = 1,
                $items = self.$menu.find("li.inlinemenu-item"),
                $view = self.$menu.find("ul.dropdown-menu"),
                itemHeight;

            if ($items.length !== 0) {
                itemHeight = $($items[0]).height();
                if (itemHeight) {
                    // round down to integer value
                    itemsPerPage = Math.floor($view.height() / itemHeight);
                    itemsPerPage = Math.max(1, Math.min(itemsPerPage, $items.length));
                }
            }

            return itemsPerPage;
        }

        // If we're no longer visible, skip handling the key and end the session.
        if (!this.isOpen()) {
            this.handleClose();
            return false;
        }

        // (page) up, (page) down, enter are handled by the list
        if ((event.type === "keydown") && this.isHandlingKeyCode(event)) {
            keyCode = event.keyCode;

            if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) {
                event.stopImmediatePropagation();
                this.handleClose();

                return false;
            } else if (event.shiftKey &&
                    (event.keyCode === KeyEvent.DOM_VK_UP ||
                     event.keyCode === KeyEvent.DOM_VK_DOWN ||
                     event.keyCode === KeyEvent.DOM_VK_PAGE_UP ||
                     event.keyCode === KeyEvent.DOM_VK_PAGE_DOWN)) {
                this.handleClose();
                // Let the event bubble.
                return false;
            } else if (keyCode === KeyEvent.DOM_VK_UP) {
                _rotateSelection.call(this, -1);
            } else if (keyCode === KeyEvent.DOM_VK_DOWN) {
                _rotateSelection.call(this, 1);
            } else if (keyCode === KeyEvent.DOM_VK_PAGE_UP) {
                _rotateSelection.call(this, -_itemsPerPage());
            } else if (keyCode === KeyEvent.DOM_VK_PAGE_DOWN) {
                _rotateSelection.call(this, _itemsPerPage());
            } else if (this.selectedIndex !== -1 &&
                    (keyCode === KeyEvent.DOM_VK_RETURN)) {
                // Trigger a click handler to commmit the selected item
                $(this.$menu.find("li.inlinemenu-item")[this.selectedIndex]).trigger("click");
            } else {
                return false;
            }

            event.stopImmediatePropagation();
            event.preventDefault();
            return true;
        }

        return false;
    };

    /**
     * Is the Inline menu open?
     *
     * @return {boolean}
     */
    InlineMenu.prototype.isOpen = function () {
        // We don't get a notification when the dropdown closes. The best
        // we can do is keep an "opened" flag and check to see if we
        // still have the "open" class applied.
        if (this.opened && !this.$menu.hasClass("open")) {
            this.opened = false;
        }

        return this.opened;
    };

    /**
     * Displays the menu at the current cursor position
     *
     * @param {Array.<{id: number, name: string>} hints
     */
    InlineMenu.prototype.open = function (items) {
        Menus.closeAll();

        this._buildListView(items);

        if (this.items.length) {
            // Need to add the menu to the DOM before trying to calculate its ideal location.
            $("#inlinemenu-menu-bar > ul").append(this.$menu);

            var menuPos = this._calcMenuLocation();

            this.$menu.addClass("open")
                .css({"left": menuPos.left, "top": menuPos.top, "width": menuPos.width + "px"});
            this.opened = true;

            KeyBindingManager.addGlobalKeydownHook(this._keydownHook);
        }
    };

    /**
     * Displays the last menu which was closed due to Scrolling
     */
    InlineMenu.prototype.openRemovedMenu = function () {
        if (this.opened === true) {
            if (this.$menu && !this.$menu.hasClass("open")) {
                var menuPos = this._calcMenuLocation();
                this.$menu.addClass("open")
                    .css({"left": menuPos.left, "top": menuPos.top, "width": menuPos.width + "px"});
            }
        }
    };

    /**
     * Closes the menu
     */
    InlineMenu.prototype.close = function () {
        this.opened = false;

        if (this.$menu) {
            this.$menu.removeClass("open");
            PopUpManager.removePopUp(this.$menu);
            this.$menu.remove();
        }

        KeyBindingManager.removeGlobalKeydownHook(this._keydownHook);
    };

    /**
     * Set the menu selection callback function
     *
     * @param {Function} callback
     */
    InlineMenu.prototype.onSelect = function (callback) {
        this.handleSelect = callback;
    };

    /**
     * Set the hover callback function
     *
     * @param {Function} callback
     */
    InlineMenu.prototype.onHover = function (callback) {
        this.handleHover = callback;
    };

    /**
     * Set the menu closure callback function
     *
     * @param {Function} callback
     */
    InlineMenu.prototype.onClose = function (callback) {
        this.handleClose = callback;
    };

    // Define public API
    exports.InlineMenu = InlineMenu;
});