autocomplete.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. /* ***** BEGIN LICENSE BLOCK *****
  2. * Distributed under the BSD license:
  3. *
  4. * Copyright (c) 2012, Ajax.org B.V.
  5. * All rights reserved.
  6. *
  7. * Redistribution and use in source and binary forms, with or without
  8. * modification, are permitted provided that the following conditions are met:
  9. * * Redistributions of source code must retain the above copyright
  10. * notice, this list of conditions and the following disclaimer.
  11. * * Redistributions in binary form must reproduce the above copyright
  12. * notice, this list of conditions and the following disclaimer in the
  13. * documentation and/or other materials provided with the distribution.
  14. * * Neither the name of Ajax.org B.V. nor the
  15. * names of its contributors may be used to endorse or promote products
  16. * derived from this software without specific prior written permission.
  17. *
  18. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
  19. * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  20. * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  21. * DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY
  22. * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  23. * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  24. * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
  25. * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  26. * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  27. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  28. *
  29. * ***** END LICENSE BLOCK ***** */
  30. define(function(require, exports, module) {
  31. "use strict";
  32. var HashHandler = require("./keyboard/hash_handler").HashHandler;
  33. var AcePopup = require("./autocomplete/popup").AcePopup;
  34. var util = require("./autocomplete/util");
  35. var event = require("./lib/event");
  36. var lang = require("./lib/lang");
  37. var snippetManager = require("./snippets").snippetManager;
  38. var Autocomplete = function() {
  39. this.keyboardHandler = new HashHandler();
  40. this.keyboardHandler.bindKeys(this.commands);
  41. this.blurListener = this.blurListener.bind(this);
  42. this.changeListener = this.changeListener.bind(this);
  43. this.mousedownListener = this.mousedownListener.bind(this);
  44. this.mousewheelListener = this.mousewheelListener.bind(this);
  45. this.changeTimer = lang.delayedCall(function() {
  46. this.updateCompletions(true);
  47. }.bind(this))
  48. };
  49. (function() {
  50. this.$init = function() {
  51. this.popup = new AcePopup(document.body || document.documentElement);
  52. this.popup.on("click", function(e) {
  53. this.insertMatch();
  54. e.stop();
  55. }.bind(this));
  56. };
  57. this.openPopup = function(editor, prefix, keepPopupPosition) {
  58. if (!this.popup)
  59. this.$init();
  60. this.popup.setData(this.completions.filtered);
  61. var renderer = editor.renderer;
  62. if (!keepPopupPosition) {
  63. this.popup.setRow(0);
  64. this.popup.setFontSize(editor.getFontSize());
  65. var lineHeight = renderer.layerConfig.lineHeight;
  66. var pos = renderer.$cursorLayer.getPixelPosition(this.base, true);
  67. pos.left -= this.popup.getTextLeftOffset();
  68. var rect = editor.container.getBoundingClientRect();
  69. pos.top += rect.top - renderer.layerConfig.offset;
  70. pos.left += rect.left - editor.renderer.scrollLeft;
  71. pos.left += renderer.$gutterLayer.gutterWidth;
  72. this.popup.show(pos, lineHeight);
  73. }
  74. };
  75. this.detach = function() {
  76. this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
  77. this.editor.off("changeSelection", this.changeListener);
  78. this.editor.off("blur", this.changeListener);
  79. this.editor.off("mousedown", this.mousedownListener);
  80. this.editor.off("mousewheel", this.mousewheelListener);
  81. this.changeTimer.cancel();
  82. if (this.popup)
  83. this.popup.hide();
  84. this.activated = false;
  85. this.completions = this.base = null;
  86. };
  87. this.changeListener = function(e) {
  88. var cursor = this.editor.selection.lead;
  89. if (cursor.row != this.base.row || cursor.column < this.base.column) {
  90. this.detach();
  91. }
  92. if (this.activated)
  93. this.changeTimer.schedule();
  94. else
  95. this.detach();
  96. };
  97. this.blurListener = function() {
  98. if (document.activeElement != this.editor.textInput.getElement())
  99. this.detach();
  100. };
  101. this.mousedownListener = function(e) {
  102. this.detach();
  103. };
  104. this.mousewheelListener = function(e) {
  105. this.detach();
  106. };
  107. this.goTo = function(where) {
  108. var row = this.popup.getRow();
  109. var max = this.popup.session.getLength() - 1;
  110. switch(where) {
  111. case "up": row = row < 0 ? max : row - 1; break;
  112. case "down": row = row >= max ? -1 : row + 1; break;
  113. case "start": row = 0; break;
  114. case "end": row = max; break;
  115. }
  116. this.popup.setRow(row);
  117. };
  118. this.insertMatch = function(data) {
  119. if (!data)
  120. data = this.popup.getData(this.popup.getRow());
  121. if (!data)
  122. return false;
  123. if (data.completer && data.completer.insertMatch) {
  124. data.completer.insertMatch(this.editor);
  125. } else {
  126. if (this.completions.filterText) {
  127. var ranges = this.editor.selection.getAllRanges();
  128. for (var i = 0, range; range = ranges[i]; i++) {
  129. range.start.column -= this.completions.filterText.length;
  130. this.editor.session.remove(range);
  131. }
  132. }
  133. if (data.snippet)
  134. snippetManager.insertSnippet(this.editor, data.snippet);
  135. else
  136. this.editor.execCommand("insertstring", data.value || data);
  137. }
  138. this.detach();
  139. };
  140. this.commands = {
  141. "Up": function(editor) { editor.completer.goTo("up"); },
  142. "Down": function(editor) { editor.completer.goTo("down"); },
  143. "Ctrl-Up|Ctrl-Home": function(editor) { editor.completer.goTo("start"); },
  144. "Ctrl-Down|Ctrl-End": function(editor) { editor.completer.goTo("end"); },
  145. "Esc": function(editor) { editor.completer.detach(); },
  146. "Space": function(editor) { editor.completer.detach(); editor.insert(" ");},
  147. "Return": function(editor) { editor.completer.insertMatch(); },
  148. "Shift-Return": function(editor) { editor.completer.insertMatch(true); },
  149. "Tab": function(editor) { editor.completer.insertMatch(); },
  150. "PageUp": function(editor) { editor.completer.popup.gotoPageUp(); },
  151. "PageDown": function(editor) { editor.completer.popup.gotoPageDown(); }
  152. };
  153. this.gatherCompletions = function(editor, callback) {
  154. var session = editor.getSession();
  155. var pos = editor.getCursorPosition();
  156. var line = session.getLine(pos.row);
  157. var prefix = util.retrievePrecedingIdentifier(line, pos.column);
  158. this.base = editor.getCursorPosition();
  159. this.base.column -= prefix.length;
  160. var matches = [];
  161. util.parForEach(editor.completers, function(completer, next) {
  162. completer.getCompletions(editor, session, pos, prefix, function(err, results) {
  163. if (!err)
  164. matches = matches.concat(results);
  165. next();
  166. });
  167. }, function() {
  168. callback(null, {
  169. prefix: prefix,
  170. matches: matches
  171. });
  172. });
  173. return true;
  174. };
  175. this.showPopup = function(editor) {
  176. if (this.editor)
  177. this.detach();
  178. this.activated = true;
  179. this.editor = editor;
  180. if (editor.completer != this) {
  181. if (editor.completer)
  182. editor.completer.detach();
  183. editor.completer = this;
  184. }
  185. editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
  186. editor.on("changeSelection", this.changeListener);
  187. editor.on("blur", this.blurListener);
  188. editor.on("mousedown", this.mousedownListener);
  189. editor.on("mousewheel", this.mousewheelListener);
  190. this.updateCompletions();
  191. };
  192. this.updateCompletions = function(keepPopupPosition) {
  193. if (keepPopupPosition && this.base && this.completions) {
  194. var pos = this.editor.getCursorPosition();
  195. var prefix = this.editor.session.getTextRange({start: this.base, end: pos});
  196. if (prefix == this.completions.filterText)
  197. return;
  198. this.completions.setFilter(prefix);
  199. if (!this.completions.filtered.length)
  200. return this.detach();
  201. this.openPopup(this.editor, prefix, keepPopupPosition);
  202. return;
  203. }
  204. this.gatherCompletions(this.editor, function(err, results) {
  205. var matches = results && results.matches;
  206. if (!matches || !matches.length)
  207. return this.detach();
  208. // TODO reenable this when we have proper change tracking
  209. // if (matches.length == 1)
  210. // return this.insertMatch(matches[0]);
  211. this.completions = new FilteredList(matches);
  212. this.completions.setFilter(results.prefix);
  213. if (!this.completions.filtered.length)
  214. return this.detach();
  215. this.openPopup(this.editor, results.prefix, keepPopupPosition);
  216. }.bind(this));
  217. };
  218. this.cancelContextMenu = function() {
  219. var stop = function(e) {
  220. this.editor.off("nativecontextmenu", stop);
  221. if (e && e.domEvent)
  222. event.stopEvent(e.domEvent);
  223. }.bind(this);
  224. setTimeout(stop, 10);
  225. this.editor.on("nativecontextmenu", stop);
  226. };
  227. }).call(Autocomplete.prototype);
  228. Autocomplete.startCommand = {
  229. name: "startAutocomplete",
  230. exec: function(editor) {
  231. if (!editor.completer)
  232. editor.completer = new Autocomplete();
  233. editor.completer.showPopup(editor);
  234. // needed for firefox on mac
  235. editor.completer.cancelContextMenu();
  236. },
  237. bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
  238. };
  239. var FilteredList = function(array, filterText, mutateData) {
  240. this.all = array;
  241. this.filtered = array;
  242. this.filterText = filterText || "";
  243. };
  244. (function(){
  245. this.setFilter = function(str) {
  246. if (str.length > this.filterText && str.lastIndexOf(this.filterText, 0) === 0)
  247. var matches = this.filtered;
  248. else
  249. var matches = this.all;
  250. this.filterText = str;
  251. matches = this.filterCompletions(matches, this.filterText);
  252. matches = matches.sort(function(a, b) {
  253. return b.exactMatch - a.exactMatch || b.score - a.score;
  254. });
  255. // make unique
  256. var prev = null;
  257. matches = matches.filter(function(item){
  258. var caption = item.value || item.caption || item.snippet;
  259. if (caption === prev) return false;
  260. prev = caption;
  261. return true;
  262. });
  263. this.filtered = matches;
  264. };
  265. this.filterCompletions = function(items, needle) {
  266. var results = [];
  267. var upper = needle.toUpperCase();
  268. var lower = needle.toLowerCase();
  269. loop: for (var i = 0, item; item = items[i]; i++) {
  270. var caption = item.value || item.caption || item.snippet;
  271. if (!caption) continue;
  272. var lastIndex = -1;
  273. var matchMask = 0;
  274. var penalty = 0;
  275. var index, distance;
  276. // caption char iteration is faster in Chrome but slower in Firefox, so lets use indexOf
  277. for (var j = 0; j < needle.length; j++) {
  278. // TODO add penalty on case mismatch
  279. var i1 = caption.indexOf(lower[j], lastIndex + 1);
  280. var i2 = caption.indexOf(upper[j], lastIndex + 1);
  281. index = (i1 >= 0) ? ((i2 < 0 || i1 < i2) ? i1 : i2) : i2;
  282. if (index < 0)
  283. continue loop;
  284. distance = index - lastIndex - 1;
  285. if (distance > 0) {
  286. // first char mismatch should be more sensitive
  287. if (lastIndex === -1)
  288. penalty += 10;
  289. penalty += distance;
  290. }
  291. matchMask = matchMask | (1 << index);
  292. lastIndex = index;
  293. }
  294. item.matchMask = matchMask;
  295. item.exactMatch = penalty ? 0 : 1;
  296. item.score = (item.score || 0) - penalty;
  297. results.push(item);
  298. }
  299. return results;
  300. };
  301. }).call(FilteredList.prototype);
  302. exports.Autocomplete = Autocomplete;
  303. exports.FilteredList = FilteredList;
  304. });