adobe/brackets

View on GitHub
src/editor/CodeHintList.js

Summary

Maintainability
F
5 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 CodeHintListHTML  = require("text!htmlContent/code-hint-list.html");

    /**
     * Displays a popup list of hints for a given editor context.
     *
     * @constructor
     * @param {Editor} editor
     * @param {boolean} insertHintOnTab Whether pressing tab inserts the selected hint
     * @param {number} maxResults Maximum hints displayed at once. Defaults to 50
     */
    function CodeHintList(editor, insertHintOnTab, maxResults) {

        /**
         * The list of hints to display
         *
         * @type {Array.<string|jQueryObject>}
         */
        this.hints = [];

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

        /**
         * The maximum number of hints to display. Can be overriden via maxCodeHints pref
         *
         * @type {number}
         */
        this.maxResults = ValidationUtils.isIntegerInRange(maxResults, 1, 1000) ? maxResults : 50;

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

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

        /**
         * Whether the currently selected hint should be inserted on a tab key event
         *
         * @type {boolean}
         */
        this.insertHintOnTab = insertHintOnTab;

        /**
         * Pending text insertion
         *
         * @type {string}
         */
        this.pendingText = "";

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

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

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

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

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

        // 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.$hintMenu.find("ul.dropdown-menu");

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

            if (this.handleHighlight) {
                this.handleHighlight($item.find("a"), this.$hintMenu.find("#codehint-desc"));
            }
        }
    };

    /**
     * Appends text to end of pending text.
     *
     * @param {string} text
     */
    CodeHintList.prototype.addPendingText = function (text) {
        this.pendingText += text;
    };

    /**
     * Removes text from beginning of pending text.
     *
     * @param {string} text
     */
    CodeHintList.prototype.removePendingText = function (text) {
        if (this.pendingText.indexOf(text) === 0) {
            this.pendingText = this.pendingText.slice(text.length);
        }
    };

    /**
     * Rebuilds the list items for the hint list.
     *
     * @private
     */
    CodeHintList.prototype._buildListView = function (hintObj) {
        var self            = this,
            match           = hintObj.match,
            selectInitial   = hintObj.selectInitial,
            view            = { hints: [] },
            _addHint;

        this.hints = hintObj.hints;
        this.hints.handleWideResults = hintObj.handleWideResults;
        this.enableDescription = hintObj.enableDescription;

        // if there is no match, assume name is already a formatted jQuery
        // object; otherwise, use match to format name for display.
        if (match) {
            _addHint = function (name) {
                var displayName = name.replace(
                    new RegExp(StringUtils.regexEscape(match), "i"),
                    "<strong>$&</strong>"
                );

                view.hints.push({ formattedHint: "<span>" + displayName + "</span>" });
            };
        } else {
            _addHint = function (hint) {
                view.hints.push({ formattedHint: (hint.jquery) ? "" : hint });
            };
        }

        // clear the list
        this.$hintMenu.find("li").remove();

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

                _addHint(item);
            });

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

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

            $ul.children("li").each(function (index, element) {
                var hint        = self.hints[index],
                    $element    = $(element);

                // store hint on each list item
                $element.data("hint", hint);

                // insert jQuery hint objects after the template is rendered
                if (hint.jquery) {
                    $element.find(".codehint-item").append(hint);
                }
            });

            // delegate list item events to the top-level ul list element
            $ul.on("click", "li", 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("hint"));
                }
            });

            // Lists with wide results require different formatting
            if (this.hints.handleWideResults) {
                $ul.find("li a").addClass("wide-result");
            }

            // attach to DOM
            $parent.append($ul);

            // If a a description field requested attach one
            if (this.enableDescription) {
                // Remove the desc element first to ensure DOM order
                $parent.find("#codehint-desc").remove();
                $parent.append("<div id='codehint-desc' class='dropdown-menu quiet-scrollbars'></div>");
                $ul.addClass("withDesc");
            }
            this._setSelectedIndex(selectInitial ? 0 : -1);
        }
    };

    /**
     * Computes top left location for hint list so that the list is not clipped by the window.
     * Also computes the largest available width.
     *
     * @private
     * @return {{left: number, top: number, width: number}}
     */
    CodeHintList.prototype._calcHintListLocation = function () {
        var cursor      = this.editor._codeMirror.cursorCoords(),
            posTop      = cursor.bottom,
            posLeft     = cursor.left,
            textHeight  = this.editor.getTextHeight(),
            $window     = $(window),
            $menuWindow = this.$hintMenu.children("ul"),
            $descElement = this.$hintMenu.find("#codehint-desc"),
            descOverhang = $descElement.length === 1 ? $descElement.height() : 0,
            menuHeight  = $menuWindow.outerHeight() + descOverhang;

        // TODO Ty: factor out menu repositioning logic so code hints 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) {
            posLeft = Math.max(0, posLeft - rightOverhang);
        } else if (this.hints.handleWideResults) {
            // Right overhang is negative
            availableWidth = menuWidth + Math.abs(rightOverhang);
        }

        //Creating the offset element for hint description element
        var descOffset = this.$hintMenu.find("ul.dropdown-menu")[0].getBoundingClientRect().height;
        if (descOffset === 0) {
            descOffset = menuHeight - descOverhang;
        }
        this.$hintMenu.find("#codehint-desc").css("margin-top", descOffset - 1);

        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
     */
    CodeHintList.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_CONTROL ||
                keyCode === KeyEvent.DOM_VK_ESCAPE ||
                (ctrlKey && keyCode === KeyEvent.DOM_VK_SPACE) ||
                (keyCode === KeyEvent.DOM_VK_TAB && this.insertHintOnTab));
    };

    /**
     * Convert keydown events into hint list navigation actions.
     *
     * @param {KeyBoardEvent} keyEvent
     * @param {bool} isFakeKeydown - True if faked key down call (for example calling CTRL+Space while hints are open)
     */
    CodeHintList.prototype._keydownHook = function (event, isFakeKeydown) {
        var keyCode,
            self = this;

        // positive distance rotates down; negative distance rotates up
        function _rotateSelection(distance) {
            var len = Math.min(self.hints.length, self.maxResults),
                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.$hintMenu.find("li"),
                $view = self.$hintMenu.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 and tab key are handled by the list
        if ((event.type === "keydown" || isFakeKeydown) && 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 ||
                    (event.ctrlKey && keyCode === KeyEvent.DOM_VK_SPACE)) {
                _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 ||
                    (keyCode === KeyEvent.DOM_VK_TAB && this.insertHintOnTab))) {

                if (this.pendingText) {
                    // Issues #5003: We received a "selection" key while there is "pending
                    // text". This is rare but can happen because CM uses polling, so we
                    // can receive key events while CM is waiting for timeout to expire.
                    // Pending text may dismiss the list, or it may cause a valid selection
                    // which keeps open hint list. We can compare pending text against
                    // list to determine whether list is dismissed or not, but to handle
                    // inserting selection in the page we'd need to either:
                    // 1. Synchronously force CodeMirror to poll (but there is not
                    //    yet a public API for that).
                    // 2. Pass pending text back to where text gets inserted, which
                    //    means it would need to be implemented for every HintProvider!
                    // You have to be typing so fast to hit this case, that's it's
                    // highly unlikely that inserting something from list was the intent,
                    // which makes this pretty rare, so case #2 is not worth implementing.
                    // If case #1 gets implemented, then we may want to use it here.
                    // So, assume that pending text dismisses hints and let event bubble.
                    return false;
                }

                // Trigger a click handler to commmit the selected item
                $(this.$hintMenu.find("li")[this.selectedIndex]).trigger("click");
            } else {
                // Let the event bubble.
                return false;
            }

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

        // If we didn't handle it, let other global keydown hooks handle it.
        return false;
    };

    /**
     * Is the CodeHintList open?
     *
     * @return {boolean}
     */
    CodeHintList.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.$hintMenu.hasClass("open")) {
            this.opened = false;
        }

        return this.opened;
    };

    /**
     * Displays the hint list at the current cursor position
     *
     * @param {{hints: Array.<string|jQueryObject>, match: string,
     *          selectInitial: boolean}} hintObj
     */
    CodeHintList.prototype.open = function (hintObj) {
        Menus.closeAll();
        this._buildListView(hintObj);

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

            var hintPos = this._calcHintListLocation();

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

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

    /**
     * Updates the (already open) hint list window with new hints
     *
     * @param {{hints: Array.<string|jQueryObject>, match: string,
     *          selectInitial: boolean}} hintObj
     */
    CodeHintList.prototype.update = function (hintObj) {
        this.$hintMenu.addClass("apply-transition");
        this._buildListView(hintObj);

        // Update the CodeHintList location
        if (this.hints.length) {
            var hintPos = this._calcHintListLocation();
            this.$hintMenu.css({"left": hintPos.left, "top": hintPos.top,
                                "width": hintPos.width + "px"});
        }
    };
    /**
     * Calls the move up keybind to move hint suggestion selector
     *
     * @param {KeyBoardEvent} keyEvent
     */
    CodeHintList.prototype.callMoveUp = function (event) {
        this._keydownHook(event, true);
    };
    /**
     * Closes the hint list
     */
    CodeHintList.prototype.close = function () {
        this.opened = false;

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

        KeyBindingManager.removeGlobalKeydownHook(this._keydownHook);
    };

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

    /**
      * Set the hint list highlight callback function
      *
      * @param {Function} callback
      */
    CodeHintList.prototype.onHighlight = function (callback) {
        this.handleHighlight = callback;
    };

    /**
     * Set the hint list closure callback function
     *
     * @param {Function} callback
     */
    CodeHintList.prototype.onClose = function (callback) {
        // TODO: Due to #1381, this won't get called if the user clicks out of
        // the code hint menu. That's (sort of) okay right now since it doesn't
        // really matter if a single old invisible code hint list is lying
        // around (it will ignore keydown events, and it'll get closed the next
        // time the user pops up a code hint). Once #1381 is fixed this issue
        // should go away.
        this.handleClose = callback;
    };


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