src/editor/CodeHintList.js
/*
* 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;
});