/* ***** BEGIN LICENSE BLOCK ***** * Distributed under the BSD license: * * Copyright (c) 2012, Ajax.org B.V. * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of Ajax.org B.V. nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * * ***** END LICENSE BLOCK ***** */ define(function(ace_require, exports, module) { "use strict"; var HashHandler = ace_require("./keyboard/hash_handler").HashHandler; var AcePopup = ace_require("./autocomplete/popup").AcePopup; var util = ace_require("./autocomplete/util"); var lang = ace_require("./lib/lang"); var dom = ace_require("./lib/dom"); var snippetManager = ace_require("./snippets").snippetManager; var config = ace_require("./config"); var Autocomplete = function() { this.autoInsert = false; this.autoSelect = true; this.exactMatch = false; this.gatherCompletionsId = 0; this.keyboardHandler = new HashHandler(); this.keyboardHandler.bindKeys(this.commands); this.blurListener = this.blurListener.bind(this); this.changeListener = this.changeListener.bind(this); this.mousedownListener = this.mousedownListener.bind(this); this.mousewheelListener = this.mousewheelListener.bind(this); this.changeTimer = lang.delayedCall(function() { this.updateCompletions(true); }.bind(this)); this.tooltipTimer = lang.delayedCall(this.updateDocTooltip.bind(this), 50); }; (function() { this.$init = function() { this.popup = new AcePopup(document.body || document.documentElement); this.popup.on("click", function(e) { this.insertMatch(); e.stop(); }.bind(this)); this.popup.focus = this.editor.focus.bind(this.editor); this.popup.on("show", this.tooltipTimer.bind(null, null)); this.popup.on("select", this.tooltipTimer.bind(null, null)); this.popup.on("changeHoverMarker", this.tooltipTimer.bind(null, null)); return this.popup; }; this.getPopup = function() { return this.popup || this.$init(); }; this.openPopup = function(editor, prefix, keepPopupPosition) { if (!this.popup) this.$init(); this.popup.autoSelect = this.autoSelect; this.popup.setData(this.completions.filtered, this.completions.filterText); editor.keyBinding.addKeyboardHandler(this.keyboardHandler); var renderer = editor.renderer; this.popup.setRow(this.autoSelect ? 0 : -1); if (!keepPopupPosition) { this.popup.setTheme(editor.getTheme()); this.popup.setFontSize(editor.getFontSize()); var lineHeight = renderer.layerConfig.lineHeight; var pos = renderer.$cursorLayer.getPixelPosition(this.base, true); pos.left -= this.popup.getTextLeftOffset(); var rect = editor.container.getBoundingClientRect(); pos.top += rect.top - renderer.layerConfig.offset; pos.left += rect.left - editor.renderer.scrollLeft; pos.left += renderer.gutterWidth; this.popup.show(pos, lineHeight); } else if (keepPopupPosition && !prefix) { this.detach(); } }; this.detach = function() { this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler); this.editor.off("changeSelection", this.changeListener); this.editor.off("blur", this.blurListener); this.editor.off("mousedown", this.mousedownListener); this.editor.off("mousewheel", this.mousewheelListener); this.changeTimer.cancel(); this.hideDocTooltip(); this.gatherCompletionsId += 1; if (this.popup && this.popup.isOpen) this.popup.hide(); if (this.base) this.base.detach(); this.activated = false; this.completions = this.base = null; }; this.changeListener = function(e) { var cursor = this.editor.selection.lead; if (cursor.row != this.base.row || cursor.column < this.base.column) { this.detach(); } if (this.activated) this.changeTimer.schedule(); else this.detach(); }; this.blurListener = function(e) { // we have to check if activeElement is a child of popup because // on IE preventDefault doesn't stop scrollbar from being focussed var el = document.activeElement; var text = this.editor.textInput.getElement(); var fromTooltip = e.relatedTarget && this.tooltipNode && this.tooltipNode.contains(e.relatedTarget); var container = this.popup && this.popup.container; if (el != text && el.parentNode != container && !fromTooltip && el != this.tooltipNode && e.relatedTarget != text ) { this.detach(); } }; this.mousedownListener = function(e) { this.detach(); }; this.mousewheelListener = function(e) { this.detach(); }; this.goTo = function(where) { this.popup.goTo(where); }; this.insertMatch = function(data, options) { if (!data) data = this.popup.getData(this.popup.getRow()); if (!data) return false; this.editor.startOperation({command: {name: "insertMatch"}}); if (data.completer && data.completer.insertMatch) { data.completer.insertMatch(this.editor, data); } else { // TODO add support for options.deleteSuffix if (this.completions.filterText) { var ranges = this.editor.selection.getAllRanges(); for (var i = 0, range; range = ranges[i]; i++) { range.start.column -= this.completions.filterText.length; this.editor.session.remove(range); } } if (data.snippet) snippetManager.insertSnippet(this.editor, data.snippet); else this.editor.execCommand("insertstring", data.value || data); } this.detach(); this.editor.endOperation(); }; this.commands = { "Up": function(editor) { editor.completer.goTo("up"); }, "Down": function(editor) { editor.completer.goTo("down"); }, "Ctrl-Up|Ctrl-Home": function(editor) { editor.completer.goTo("start"); }, "Ctrl-Down|Ctrl-End": function(editor) { editor.completer.goTo("end"); }, "Esc": function(editor) { editor.completer.detach(); }, "Return": function(editor) { return editor.completer.insertMatch(); }, "Shift-Return": function(editor) { editor.completer.insertMatch(null, {deleteSuffix: true}); }, "Tab": function(editor) { var result = editor.completer.insertMatch(); if (!result && !editor.tabstopManager) editor.completer.goTo("down"); else return result; }, "PageUp": function(editor) { editor.completer.popup.gotoPageUp(); }, "PageDown": function(editor) { editor.completer.popup.gotoPageDown(); } }; this.gatherCompletions = function(editor, callback) { var session = editor.getSession(); var pos = editor.getCursorPosition(); var prefix = util.getCompletionPrefix(editor); this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length); this.base.$insertRight = true; var matches = []; var total = editor.completers.length; editor.completers.forEach(function(completer, i) { completer.getCompletions(editor, session, pos, prefix, function(err, results) { if (!err && results) matches = matches.concat(results); // Fetch prefix again, because they may have changed by now callback(null, { prefix: util.getCompletionPrefix(editor), matches: matches, finished: (--total === 0) }); }); }); return true; }; this.showPopup = function(editor, options) { if (this.editor) this.detach(); this.activated = true; this.editor = editor; if (editor.completer != this) { if (editor.completer) editor.completer.detach(); editor.completer = this; } editor.on("changeSelection", this.changeListener); editor.on("blur", this.blurListener); editor.on("mousedown", this.mousedownListener); editor.on("mousewheel", this.mousewheelListener); this.updateCompletions(false, options); }; this.updateCompletions = function(keepPopupPosition, options) { if (keepPopupPosition && this.base && this.completions) { var pos = this.editor.getCursorPosition(); var prefix = this.editor.session.getTextRange({start: this.base, end: pos}); if (prefix == this.completions.filterText) return; this.completions.setFilter(prefix); if (!this.completions.filtered.length) return this.detach(); if (this.completions.filtered.length == 1 && this.completions.filtered[0].value == prefix && !this.completions.filtered[0].snippet) return this.detach(); this.openPopup(this.editor, prefix, keepPopupPosition); return; } if (options && options.matches) { var pos = this.editor.getSelectionRange().start; this.base = this.editor.session.doc.createAnchor(pos.row, pos.column); this.base.$insertRight = true; this.completions = new FilteredList(options.matches); return this.openPopup(this.editor, "", keepPopupPosition); } // Save current gatherCompletions session, session is close when a match is insert var _id = this.gatherCompletionsId; this.gatherCompletions(this.editor, function(err, results) { // Only detach if result gathering is finished var detachIfFinished = function() { if (!results.finished) return; return this.detach(); }.bind(this); var prefix = results.prefix; var matches = results && results.matches; if (!matches || !matches.length) return detachIfFinished(); // Wrong prefix or wrong session -> ignore if (prefix.indexOf(results.prefix) !== 0 || _id != this.gatherCompletionsId) return; this.completions = new FilteredList(matches); if (this.exactMatch) this.completions.exactMatch = true; this.completions.setFilter(prefix); var filtered = this.completions.filtered; // No results if (!filtered.length) return detachIfFinished(); // One result equals to the prefix if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet) return detachIfFinished(); // Autoinsert if one result if (this.autoInsert && filtered.length == 1 && results.finished) return this.insertMatch(filtered[0]); this.openPopup(this.editor, prefix, keepPopupPosition); }.bind(this)); }; this.cancelContextMenu = function() { this.editor.$mouseHandler.cancelContextMenu(); }; this.updateDocTooltip = function() { var popup = this.popup; var all = popup.data; var selected = all && (all[popup.getHoveredRow()] || all[popup.getRow()]); var doc = null; if (!selected || !this.editor || !this.popup.isOpen) return this.hideDocTooltip(); this.editor.completers.some(function(completer) { if (completer.getDocTooltip) doc = completer.getDocTooltip(selected); return doc; }); if (!doc && typeof selected != "string") doc = selected; if (typeof doc == "string") doc = {docText: doc}; if (!doc || !(doc.docHTML || doc.docText)) return this.hideDocTooltip(); this.showDocTooltip(doc); }; this.showDocTooltip = function(item) { if (!this.tooltipNode) { this.tooltipNode = dom.createElement("div"); this.tooltipNode.className = "ace_tooltip ace_doc-tooltip"; this.tooltipNode.style.margin = 0; this.tooltipNode.style.pointerEvents = "auto"; this.tooltipNode.tabIndex = -1; this.tooltipNode.onblur = this.blurListener.bind(this); this.tooltipNode.onclick = this.onTooltipClick.bind(this); } var tooltipNode = this.tooltipNode; if (item.docHTML) { tooltipNode.innerHTML = item.docHTML; } else if (item.docText) { tooltipNode.textContent = item.docText; } if (!tooltipNode.parentNode) document.body.appendChild(tooltipNode); var popup = this.popup; var rect = popup.container.getBoundingClientRect(); tooltipNode.style.top = popup.container.style.top; tooltipNode.style.bottom = popup.container.style.bottom; tooltipNode.style.display = "block"; if (window.innerWidth - rect.right < 320) { if (rect.left < 320) { if(popup.isTopdown) { tooltipNode.style.top = rect.bottom + "px"; tooltipNode.style.left = rect.left + "px"; tooltipNode.style.right = ""; tooltipNode.style.bottom = ""; } else { tooltipNode.style.top = popup.container.offsetTop - tooltipNode.offsetHeight + "px"; tooltipNode.style.left = rect.left + "px"; tooltipNode.style.right = ""; tooltipNode.style.bottom = ""; } } else { tooltipNode.style.right = window.innerWidth - rect.left + "px"; tooltipNode.style.left = ""; } } else { tooltipNode.style.left = (rect.right + 1) + "px"; tooltipNode.style.right = ""; } }; this.hideDocTooltip = function() { this.tooltipTimer.cancel(); if (!this.tooltipNode) return; var el = this.tooltipNode; if (!this.editor.isFocused() && document.activeElement == el) this.editor.focus(); this.tooltipNode = null; if (el.parentNode) el.parentNode.removeChild(el); }; this.onTooltipClick = function(e) { var a = e.target; while (a && a != this.tooltipNode) { if (a.nodeName == "A" && a.href) { a.rel = "noreferrer"; a.target = "_blank"; break; } a = a.parentNode; } }; this.destroy = function() { this.detach(); if (this.popup) { this.popup.destroy(); var el = this.popup.container; if (el && el.parentNode) el.parentNode.removeChild(el); } if (this.editor && this.editor.completer == this) this.editor.completer == null; this.popup = null; }; }).call(Autocomplete.prototype); Autocomplete.for = function(editor) { if (editor.completer) { return editor.completer; } if (config.get("sharedPopups")) { if (!Autocomplete.$shared) Autocomplete.$sharedInstance = new Autocomplete(); editor.completer = Autocomplete.$sharedInstance; } else { editor.completer = new Autocomplete(); editor.once("destroy", function(e, editor) { editor.completer.destroy(); }); } return editor.completer; }; Autocomplete.startCommand = { name: "startAutocomplete", exec: function(editor, options) { var completer = Autocomplete.for(editor); completer.autoInsert = false; completer.autoSelect = true; completer.showPopup(editor, options); // prevent ctrl-space opening context menu on firefox on mac completer.cancelContextMenu(); }, bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space" }; var FilteredList = function(array, filterText) { this.all = array; this.filtered = array; this.filterText = filterText || ""; this.exactMatch = false; }; (function(){ this.setFilter = function(str) { if (str.length > this.filterText && str.lastIndexOf(this.filterText, 0) === 0) var matches = this.filtered; else var matches = this.all; this.filterText = str; matches = this.filterCompletions(matches, this.filterText); matches = matches.sort(function(a, b) { return b.exactMatch - a.exactMatch || b.$score - a.$score || (a.caption || a.value).localeCompare(b.caption || b.value); }); // make unique var prev = null; matches = matches.filter(function(item){ var caption = item.snippet || item.caption || item.value; if (caption === prev) return false; prev = caption; return true; }); this.filtered = matches; }; this.filterCompletions = function(items, needle) { var results = []; var upper = needle.toUpperCase(); var lower = needle.toLowerCase(); loop: for (var i = 0, item; item = items[i]; i++) { var caption = item.caption || item.value || item.snippet; if (!caption) continue; var lastIndex = -1; var matchMask = 0; var penalty = 0; var index, distance; if (this.exactMatch) { if (needle !== caption.substr(0, needle.length)) continue loop; } else { /** * It is for situation then, for example, we find some like 'tab' in item.value="Check the table" * and want to see "Check the TABle" but see "Check The tABle". */ var fullMatchIndex = caption.toLowerCase().indexOf(lower); if (fullMatchIndex > -1) { penalty = fullMatchIndex; } else { // caption char iteration is faster in Chrome but slower in Firefox, so lets use indexOf for (var j = 0; j < needle.length; j++) { // TODO add penalty on case mismatch var i1 = caption.indexOf(lower[j], lastIndex + 1); var i2 = caption.indexOf(upper[j], lastIndex + 1); index = (i1 >= 0) ? ((i2 < 0 || i1 < i2) ? i1 : i2) : i2; if (index < 0) continue loop; distance = index - lastIndex - 1; if (distance > 0) { // first char mismatch should be more sensitive if (lastIndex === -1) penalty += 10; penalty += distance; matchMask = matchMask | (1 << j); } lastIndex = index; } } } item.matchMask = matchMask; item.exactMatch = penalty ? 0 : 1; item.$score = (item.score || 0) - penalty; results.push(item); } return results; }; }).call(FilteredList.prototype); exports.Autocomplete = Autocomplete; exports.FilteredList = FilteredList; });