autocomplete.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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(ace_require, exports, module) {
  31. "use strict";
  32. var HashHandler = ace_require("./keyboard/hash_handler").HashHandler;
  33. var AcePopup = ace_require("./autocomplete/popup").AcePopup;
  34. var util = ace_require("./autocomplete/util");
  35. var lang = ace_require("./lib/lang");
  36. var dom = ace_require("./lib/dom");
  37. var snippetManager = ace_require("./snippets").snippetManager;
  38. var config = ace_require("./config");
  39. var Autocomplete = function() {
  40. this.autoInsert = false;
  41. this.autoSelect = true;
  42. this.exactMatch = false;
  43. this.gatherCompletionsId = 0;
  44. this.keyboardHandler = new HashHandler();
  45. this.keyboardHandler.bindKeys(this.commands);
  46. this.blurListener = this.blurListener.bind(this);
  47. this.changeListener = this.changeListener.bind(this);
  48. this.mousedownListener = this.mousedownListener.bind(this);
  49. this.mousewheelListener = this.mousewheelListener.bind(this);
  50. this.changeTimer = lang.delayedCall(function() {
  51. this.updateCompletions(true);
  52. }.bind(this));
  53. this.tooltipTimer = lang.delayedCall(this.updateDocTooltip.bind(this), 50);
  54. };
  55. (function() {
  56. this.$init = function() {
  57. this.popup = new AcePopup(document.body || document.documentElement);
  58. this.popup.on("click", function(e) {
  59. this.insertMatch();
  60. e.stop();
  61. }.bind(this));
  62. this.popup.focus = this.editor.focus.bind(this.editor);
  63. this.popup.on("show", this.tooltipTimer.bind(null, null));
  64. this.popup.on("select", this.tooltipTimer.bind(null, null));
  65. this.popup.on("changeHoverMarker", this.tooltipTimer.bind(null, null));
  66. return this.popup;
  67. };
  68. this.getPopup = function() {
  69. return this.popup || this.$init();
  70. };
  71. this.openPopup = function(editor, prefix, keepPopupPosition) {
  72. if (!this.popup)
  73. this.$init();
  74. this.popup.autoSelect = this.autoSelect;
  75. this.popup.setData(this.completions.filtered, this.completions.filterText);
  76. editor.keyBinding.addKeyboardHandler(this.keyboardHandler);
  77. var renderer = editor.renderer;
  78. this.popup.setRow(this.autoSelect ? 0 : -1);
  79. if (!keepPopupPosition) {
  80. this.popup.setTheme(editor.getTheme());
  81. this.popup.setFontSize(editor.getFontSize());
  82. var lineHeight = renderer.layerConfig.lineHeight;
  83. var pos = renderer.$cursorLayer.getPixelPosition(this.base, true);
  84. pos.left -= this.popup.getTextLeftOffset();
  85. var rect = editor.container.getBoundingClientRect();
  86. pos.top += rect.top - renderer.layerConfig.offset;
  87. pos.left += rect.left - editor.renderer.scrollLeft;
  88. pos.left += renderer.gutterWidth;
  89. this.popup.show(pos, lineHeight);
  90. } else if (keepPopupPosition && !prefix) {
  91. this.detach();
  92. }
  93. };
  94. this.detach = function() {
  95. this.editor.keyBinding.removeKeyboardHandler(this.keyboardHandler);
  96. this.editor.off("changeSelection", this.changeListener);
  97. this.editor.off("blur", this.blurListener);
  98. this.editor.off("mousedown", this.mousedownListener);
  99. this.editor.off("mousewheel", this.mousewheelListener);
  100. this.changeTimer.cancel();
  101. this.hideDocTooltip();
  102. this.gatherCompletionsId += 1;
  103. if (this.popup && this.popup.isOpen)
  104. this.popup.hide();
  105. if (this.base)
  106. this.base.detach();
  107. this.activated = false;
  108. this.completions = this.base = null;
  109. };
  110. this.changeListener = function(e) {
  111. var cursor = this.editor.selection.lead;
  112. if (cursor.row != this.base.row || cursor.column < this.base.column) {
  113. this.detach();
  114. }
  115. if (this.activated)
  116. this.changeTimer.schedule();
  117. else
  118. this.detach();
  119. };
  120. this.blurListener = function(e) {
  121. // we have to check if activeElement is a child of popup because
  122. // on IE preventDefault doesn't stop scrollbar from being focussed
  123. var el = document.activeElement;
  124. var text = this.editor.textInput.getElement();
  125. var fromTooltip = e.relatedTarget && this.tooltipNode && this.tooltipNode.contains(e.relatedTarget);
  126. var container = this.popup && this.popup.container;
  127. if (el != text && el.parentNode != container && !fromTooltip
  128. && el != this.tooltipNode && e.relatedTarget != text
  129. ) {
  130. this.detach();
  131. }
  132. };
  133. this.mousedownListener = function(e) {
  134. this.detach();
  135. };
  136. this.mousewheelListener = function(e) {
  137. this.detach();
  138. };
  139. this.goTo = function(where) {
  140. this.popup.goTo(where);
  141. };
  142. this.insertMatch = function(data, options) {
  143. if (!data)
  144. data = this.popup.getData(this.popup.getRow());
  145. if (!data)
  146. return false;
  147. this.editor.startOperation({command: {name: "insertMatch"}});
  148. if (data.completer && data.completer.insertMatch) {
  149. data.completer.insertMatch(this.editor, data);
  150. } else {
  151. // TODO add support for options.deleteSuffix
  152. if (this.completions.filterText) {
  153. var ranges = this.editor.selection.getAllRanges();
  154. for (var i = 0, range; range = ranges[i]; i++) {
  155. range.start.column -= this.completions.filterText.length;
  156. this.editor.session.remove(range);
  157. }
  158. }
  159. if (data.snippet)
  160. snippetManager.insertSnippet(this.editor, data.snippet);
  161. else
  162. this.editor.execCommand("insertstring", data.value || data);
  163. }
  164. this.detach();
  165. this.editor.endOperation();
  166. };
  167. this.commands = {
  168. "Up": function(editor) { editor.completer.goTo("up"); },
  169. "Down": function(editor) { editor.completer.goTo("down"); },
  170. "Ctrl-Up|Ctrl-Home": function(editor) { editor.completer.goTo("start"); },
  171. "Ctrl-Down|Ctrl-End": function(editor) { editor.completer.goTo("end"); },
  172. "Esc": function(editor) { editor.completer.detach(); },
  173. "Return": function(editor) { return editor.completer.insertMatch(); },
  174. "Shift-Return": function(editor) { editor.completer.insertMatch(null, {deleteSuffix: true}); },
  175. "Tab": function(editor) {
  176. var result = editor.completer.insertMatch();
  177. if (!result && !editor.tabstopManager)
  178. editor.completer.goTo("down");
  179. else
  180. return result;
  181. },
  182. "PageUp": function(editor) { editor.completer.popup.gotoPageUp(); },
  183. "PageDown": function(editor) { editor.completer.popup.gotoPageDown(); }
  184. };
  185. this.gatherCompletions = function(editor, callback) {
  186. var session = editor.getSession();
  187. var pos = editor.getCursorPosition();
  188. var prefix = util.getCompletionPrefix(editor);
  189. this.base = session.doc.createAnchor(pos.row, pos.column - prefix.length);
  190. this.base.$insertRight = true;
  191. var matches = [];
  192. var total = editor.completers.length;
  193. editor.completers.forEach(function(completer, i) {
  194. completer.getCompletions(editor, session, pos, prefix, function(err, results) {
  195. if (!err && results)
  196. matches = matches.concat(results);
  197. // Fetch prefix again, because they may have changed by now
  198. callback(null, {
  199. prefix: util.getCompletionPrefix(editor),
  200. matches: matches,
  201. finished: (--total === 0)
  202. });
  203. });
  204. });
  205. return true;
  206. };
  207. this.showPopup = function(editor, options) {
  208. if (this.editor)
  209. this.detach();
  210. this.activated = true;
  211. this.editor = editor;
  212. if (editor.completer != this) {
  213. if (editor.completer)
  214. editor.completer.detach();
  215. editor.completer = this;
  216. }
  217. editor.on("changeSelection", this.changeListener);
  218. editor.on("blur", this.blurListener);
  219. editor.on("mousedown", this.mousedownListener);
  220. editor.on("mousewheel", this.mousewheelListener);
  221. this.updateCompletions(false, options);
  222. };
  223. this.updateCompletions = function(keepPopupPosition, options) {
  224. if (keepPopupPosition && this.base && this.completions) {
  225. var pos = this.editor.getCursorPosition();
  226. var prefix = this.editor.session.getTextRange({start: this.base, end: pos});
  227. if (prefix == this.completions.filterText)
  228. return;
  229. this.completions.setFilter(prefix);
  230. if (!this.completions.filtered.length)
  231. return this.detach();
  232. if (this.completions.filtered.length == 1
  233. && this.completions.filtered[0].value == prefix
  234. && !this.completions.filtered[0].snippet)
  235. return this.detach();
  236. this.openPopup(this.editor, prefix, keepPopupPosition);
  237. return;
  238. }
  239. if (options && options.matches) {
  240. var pos = this.editor.getSelectionRange().start;
  241. this.base = this.editor.session.doc.createAnchor(pos.row, pos.column);
  242. this.base.$insertRight = true;
  243. this.completions = new FilteredList(options.matches);
  244. return this.openPopup(this.editor, "", keepPopupPosition);
  245. }
  246. // Save current gatherCompletions session, session is close when a match is insert
  247. var _id = this.gatherCompletionsId;
  248. this.gatherCompletions(this.editor, function(err, results) {
  249. // Only detach if result gathering is finished
  250. var detachIfFinished = function() {
  251. if (!results.finished) return;
  252. return this.detach();
  253. }.bind(this);
  254. var prefix = results.prefix;
  255. var matches = results && results.matches;
  256. if (!matches || !matches.length)
  257. return detachIfFinished();
  258. // Wrong prefix or wrong session -> ignore
  259. if (prefix.indexOf(results.prefix) !== 0 || _id != this.gatherCompletionsId)
  260. return;
  261. this.completions = new FilteredList(matches);
  262. if (this.exactMatch)
  263. this.completions.exactMatch = true;
  264. this.completions.setFilter(prefix);
  265. var filtered = this.completions.filtered;
  266. // No results
  267. if (!filtered.length)
  268. return detachIfFinished();
  269. // One result equals to the prefix
  270. if (filtered.length == 1 && filtered[0].value == prefix && !filtered[0].snippet)
  271. return detachIfFinished();
  272. // Autoinsert if one result
  273. if (this.autoInsert && filtered.length == 1 && results.finished)
  274. return this.insertMatch(filtered[0]);
  275. this.openPopup(this.editor, prefix, keepPopupPosition);
  276. }.bind(this));
  277. };
  278. this.cancelContextMenu = function() {
  279. this.editor.$mouseHandler.cancelContextMenu();
  280. };
  281. this.updateDocTooltip = function() {
  282. var popup = this.popup;
  283. var all = popup.data;
  284. var selected = all && (all[popup.getHoveredRow()] || all[popup.getRow()]);
  285. var doc = null;
  286. if (!selected || !this.editor || !this.popup.isOpen)
  287. return this.hideDocTooltip();
  288. this.editor.completers.some(function(completer) {
  289. if (completer.getDocTooltip)
  290. doc = completer.getDocTooltip(selected);
  291. return doc;
  292. });
  293. if (!doc && typeof selected != "string")
  294. doc = selected;
  295. if (typeof doc == "string")
  296. doc = {docText: doc};
  297. if (!doc || !(doc.docHTML || doc.docText))
  298. return this.hideDocTooltip();
  299. this.showDocTooltip(doc);
  300. };
  301. this.showDocTooltip = function(item) {
  302. if (!this.tooltipNode) {
  303. this.tooltipNode = dom.createElement("div");
  304. this.tooltipNode.className = "ace_tooltip ace_doc-tooltip";
  305. this.tooltipNode.style.margin = 0;
  306. this.tooltipNode.style.pointerEvents = "auto";
  307. this.tooltipNode.tabIndex = -1;
  308. this.tooltipNode.onblur = this.blurListener.bind(this);
  309. this.tooltipNode.onclick = this.onTooltipClick.bind(this);
  310. }
  311. var tooltipNode = this.tooltipNode;
  312. if (item.docHTML) {
  313. tooltipNode.innerHTML = item.docHTML;
  314. } else if (item.docText) {
  315. tooltipNode.textContent = item.docText;
  316. }
  317. if (!tooltipNode.parentNode)
  318. document.body.appendChild(tooltipNode);
  319. var popup = this.popup;
  320. var rect = popup.container.getBoundingClientRect();
  321. tooltipNode.style.top = popup.container.style.top;
  322. tooltipNode.style.bottom = popup.container.style.bottom;
  323. tooltipNode.style.display = "block";
  324. if (window.innerWidth - rect.right < 320) {
  325. if (rect.left < 320) {
  326. if(popup.isTopdown) {
  327. tooltipNode.style.top = rect.bottom + "px";
  328. tooltipNode.style.left = rect.left + "px";
  329. tooltipNode.style.right = "";
  330. tooltipNode.style.bottom = "";
  331. } else {
  332. tooltipNode.style.top = popup.container.offsetTop - tooltipNode.offsetHeight + "px";
  333. tooltipNode.style.left = rect.left + "px";
  334. tooltipNode.style.right = "";
  335. tooltipNode.style.bottom = "";
  336. }
  337. } else {
  338. tooltipNode.style.right = window.innerWidth - rect.left + "px";
  339. tooltipNode.style.left = "";
  340. }
  341. } else {
  342. tooltipNode.style.left = (rect.right + 1) + "px";
  343. tooltipNode.style.right = "";
  344. }
  345. };
  346. this.hideDocTooltip = function() {
  347. this.tooltipTimer.cancel();
  348. if (!this.tooltipNode) return;
  349. var el = this.tooltipNode;
  350. if (!this.editor.isFocused() && document.activeElement == el)
  351. this.editor.focus();
  352. this.tooltipNode = null;
  353. if (el.parentNode)
  354. el.parentNode.removeChild(el);
  355. };
  356. this.onTooltipClick = function(e) {
  357. var a = e.target;
  358. while (a && a != this.tooltipNode) {
  359. if (a.nodeName == "A" && a.href) {
  360. a.rel = "noreferrer";
  361. a.target = "_blank";
  362. break;
  363. }
  364. a = a.parentNode;
  365. }
  366. };
  367. this.destroy = function() {
  368. this.detach();
  369. if (this.popup) {
  370. this.popup.destroy();
  371. var el = this.popup.container;
  372. if (el && el.parentNode)
  373. el.parentNode.removeChild(el);
  374. }
  375. if (this.editor && this.editor.completer == this)
  376. this.editor.completer == null;
  377. this.popup = null;
  378. };
  379. }).call(Autocomplete.prototype);
  380. Autocomplete.for = function(editor) {
  381. if (editor.completer) {
  382. return editor.completer;
  383. }
  384. if (config.get("sharedPopups")) {
  385. if (!Autocomplete.$shared)
  386. Autocomplete.$sharedInstance = new Autocomplete();
  387. editor.completer = Autocomplete.$sharedInstance;
  388. } else {
  389. editor.completer = new Autocomplete();
  390. editor.once("destroy", function(e, editor) {
  391. editor.completer.destroy();
  392. });
  393. }
  394. return editor.completer;
  395. };
  396. Autocomplete.startCommand = {
  397. name: "startAutocomplete",
  398. exec: function(editor, options) {
  399. var completer = Autocomplete.for(editor);
  400. completer.autoInsert = false;
  401. completer.autoSelect = true;
  402. completer.showPopup(editor, options);
  403. // prevent ctrl-space opening context menu on firefox on mac
  404. completer.cancelContextMenu();
  405. },
  406. bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
  407. };
  408. var FilteredList = function(array, filterText) {
  409. this.all = array;
  410. this.filtered = array;
  411. this.filterText = filterText || "";
  412. this.exactMatch = false;
  413. };
  414. (function(){
  415. this.setFilter = function(str) {
  416. if (str.length > this.filterText && str.lastIndexOf(this.filterText, 0) === 0)
  417. var matches = this.filtered;
  418. else
  419. var matches = this.all;
  420. this.filterText = str;
  421. matches = this.filterCompletions(matches, this.filterText);
  422. matches = matches.sort(function(a, b) {
  423. return b.exactMatch - a.exactMatch || b.$score - a.$score
  424. || (a.caption || a.value).localeCompare(b.caption || b.value);
  425. });
  426. // make unique
  427. var prev = null;
  428. matches = matches.filter(function(item){
  429. var caption = item.snippet || item.caption || item.value;
  430. if (caption === prev) return false;
  431. prev = caption;
  432. return true;
  433. });
  434. this.filtered = matches;
  435. };
  436. this.filterCompletions = function(items, needle) {
  437. var results = [];
  438. var upper = needle.toUpperCase();
  439. var lower = needle.toLowerCase();
  440. loop: for (var i = 0, item; item = items[i]; i++) {
  441. var caption = item.caption || item.value || item.snippet;
  442. if (!caption) continue;
  443. var lastIndex = -1;
  444. var matchMask = 0;
  445. var penalty = 0;
  446. var index, distance;
  447. if (this.exactMatch) {
  448. if (needle !== caption.substr(0, needle.length))
  449. continue loop;
  450. } else {
  451. /**
  452. * It is for situation then, for example, we find some like 'tab' in item.value="Check the table"
  453. * and want to see "Check the TABle" but see "Check The tABle".
  454. */
  455. var fullMatchIndex = caption.toLowerCase().indexOf(lower);
  456. if (fullMatchIndex > -1) {
  457. penalty = fullMatchIndex;
  458. } else {
  459. // caption char iteration is faster in Chrome but slower in Firefox, so lets use indexOf
  460. for (var j = 0; j < needle.length; j++) {
  461. // TODO add penalty on case mismatch
  462. var i1 = caption.indexOf(lower[j], lastIndex + 1);
  463. var i2 = caption.indexOf(upper[j], lastIndex + 1);
  464. index = (i1 >= 0) ? ((i2 < 0 || i1 < i2) ? i1 : i2) : i2;
  465. if (index < 0)
  466. continue loop;
  467. distance = index - lastIndex - 1;
  468. if (distance > 0) {
  469. // first char mismatch should be more sensitive
  470. if (lastIndex === -1)
  471. penalty += 10;
  472. penalty += distance;
  473. matchMask = matchMask | (1 << j);
  474. }
  475. lastIndex = index;
  476. }
  477. }
  478. }
  479. item.matchMask = matchMask;
  480. item.exactMatch = penalty ? 0 : 1;
  481. item.$score = (item.score || 0) - penalty;
  482. results.push(item);
  483. }
  484. return results;
  485. };
  486. }).call(FilteredList.prototype);
  487. exports.Autocomplete = Autocomplete;
  488. exports.FilteredList = FilteredList;
  489. });