jquery.textcomplete.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. /*!
  2. * jQuery.textcomplete.js
  3. *
  4. * Repositiory: https://github.com/yuku-t/jquery-textcomplete
  5. * License: MIT
  6. * Author: Yuku Takahashi
  7. */
  8. ;(function ($) {
  9. 'use strict';
  10. /**
  11. * Exclusive execution control utility.
  12. */
  13. var lock = function (func) {
  14. var free, locked;
  15. free = function () { locked = false; };
  16. return function () {
  17. var args;
  18. if (locked) return;
  19. locked = true;
  20. args = toArray(arguments);
  21. args.unshift(free);
  22. func.apply(this, args);
  23. };
  24. };
  25. /**
  26. * Convert arguments into a real array.
  27. */
  28. var toArray = function (args) {
  29. var result;
  30. result = Array.prototype.slice.call(args);
  31. return result;
  32. };
  33. /**
  34. * Get the styles of any element from property names.
  35. */
  36. var getStyles = (function () {
  37. var color;
  38. color = $('<div></div>').css(['color']).color;
  39. if (typeof color !== 'undefined') {
  40. return function ($el, properties) {
  41. return $el.css(properties);
  42. };
  43. } else { // for jQuery 1.8 or below
  44. return function ($el, properties) {
  45. var styles;
  46. styles = {};
  47. $.each(properties, function (i, property) {
  48. styles[property] = $el.css(property);
  49. });
  50. return styles;
  51. };
  52. }
  53. })();
  54. /**
  55. * Default template function.
  56. */
  57. var identity = function (obj) { return obj; };
  58. /**
  59. * Memoize a search function.
  60. */
  61. var memoize = function (func) {
  62. var memo = {};
  63. return function (term, callback) {
  64. if (memo[term]) {
  65. callback(memo[term]);
  66. } else {
  67. func.call(this, term, function (data) {
  68. memo[term] = (memo[term] || []).concat(data);
  69. callback.apply(null, arguments);
  70. });
  71. }
  72. };
  73. };
  74. /**
  75. * Determine if the array contains a given value.
  76. */
  77. var include = function (array, value) {
  78. var i, l;
  79. if (array.indexOf) return array.indexOf(value) != -1;
  80. for (i = 0, l = array.length; i < l; i++) {
  81. if (array[i] === value) return true;
  82. }
  83. return false;
  84. };
  85. /**
  86. * Textarea manager class.
  87. */
  88. var Completer = (function () {
  89. var html, css, $baseWrapper, $baseList, _id;
  90. html = {
  91. wrapper: '<div class="textcomplete-wrapper"></div>',
  92. list: '<ul class="dropdown-menu"></ul>'
  93. };
  94. css = {
  95. wrapper: {
  96. position: 'relative'
  97. },
  98. list: {
  99. position: 'absolute',
  100. top: 0,
  101. left: 0,
  102. zIndex: '100',
  103. display: 'none'
  104. }
  105. };
  106. $baseWrapper = $(html.wrapper).css(css.wrapper);
  107. $baseList = $(html.list).css(css.list);
  108. _id = 0;
  109. function Completer($el) {
  110. var focus;
  111. this.el = $el.get(0); // textarea element
  112. focus = this.el === document.activeElement;
  113. // Cannot wrap $el at initialize method lazily due to Firefox's behavior.
  114. this.$el = wrapElement($el); // Focus is lost
  115. this.id = 'textComplete' + _id++;
  116. this.strategies = [];
  117. if (focus) {
  118. this.initialize();
  119. this.$el.focus();
  120. } else {
  121. this.$el.one('focus.textComplete', $.proxy(this.initialize, this));
  122. }
  123. }
  124. /**
  125. * Completer's public methods
  126. */
  127. $.extend(Completer.prototype, {
  128. /**
  129. * Prepare ListView and bind events.
  130. */
  131. initialize: function () {
  132. var $list, globalEvents;
  133. $list = $baseList.clone();
  134. this.listView = new ListView($list, this);
  135. this.$el
  136. .before($list)
  137. .on({
  138. 'keyup.textComplete': $.proxy(this.onKeyup, this),
  139. 'keydown.textComplete': $.proxy(this.listView.onKeydown,
  140. this.listView)
  141. });
  142. globalEvents = {};
  143. globalEvents['click.' + this.id] = $.proxy(this.onClickDocument, this);
  144. globalEvents['keyup.' + this.id] = $.proxy(this.onKeyupDocument, this);
  145. $(document).on(globalEvents);
  146. },
  147. /**
  148. * Register strategies to the completer.
  149. */
  150. register: function (strategies) {
  151. this.strategies = this.strategies.concat(strategies);
  152. },
  153. /**
  154. * Show autocomplete list next to the caret.
  155. */
  156. renderList: function (data) {
  157. if (this.clearAtNext) {
  158. this.listView.clear();
  159. this.clearAtNext = false;
  160. }
  161. if (data.length) {
  162. if (!this.listView.shown) {
  163. this.listView
  164. .setPosition(this.getCaretPosition())
  165. .clear()
  166. .activate();
  167. this.listView.strategy = this.strategy;
  168. }
  169. data = data.slice(0, this.strategy.maxCount);
  170. this.listView.render(data);
  171. }
  172. if (!this.listView.data.length && this.listView.shown) {
  173. this.listView.deactivate();
  174. }
  175. },
  176. searchCallbackFactory: function (free) {
  177. var self = this;
  178. return function (data, keep) {
  179. self.renderList(data);
  180. if (!keep) {
  181. // This is the last callback for this search.
  182. free();
  183. self.clearAtNext = true;
  184. }
  185. };
  186. },
  187. /**
  188. * Keyup event handler.
  189. */
  190. onKeyup: function (e) {
  191. var searchQuery, term;
  192. if (this.skipSearch(e)) { return; }
  193. searchQuery = this.extractSearchQuery(this.getTextFromHeadToCaret());
  194. if (searchQuery.length) {
  195. term = searchQuery[1];
  196. if (this.term === term) return; // Ignore shift-key or something.
  197. this.term = term;
  198. this.search(searchQuery);
  199. } else {
  200. this.term = null;
  201. this.listView.deactivate();
  202. }
  203. },
  204. /**
  205. * Suppress searching if it returns true.
  206. */
  207. skipSearch: function (e) {
  208. if (this.skipNextKeyup) {
  209. this.skipNextKeyup = false;
  210. return true;
  211. }
  212. switch (e.keyCode) {
  213. case 40:
  214. case 38:
  215. return true;
  216. }
  217. },
  218. onSelect: function (value) {
  219. var pre, post, newSubStr;
  220. pre = this.getTextFromHeadToCaret();
  221. post = this.el.value.substring(this.el.selectionEnd);
  222. newSubStr = this.strategy.replace(value);
  223. if ($.isArray(newSubStr)) {
  224. post = newSubStr[1] + post;
  225. newSubStr = newSubStr[0];
  226. }
  227. pre = pre.replace(this.strategy.match, newSubStr);
  228. this.$el.val(pre + post)
  229. .trigger('change')
  230. .trigger('textComplete:select', value);
  231. this.el.focus();
  232. this.el.selectionStart = this.el.selectionEnd = pre.length;
  233. this.skipNextKeyup = true;
  234. },
  235. /**
  236. * Global click event handler.
  237. */
  238. onClickDocument: function (e) {
  239. if (e.originalEvent && !e.originalEvent.keepTextCompleteDropdown) {
  240. this.listView.deactivate();
  241. }
  242. },
  243. /**
  244. * Global keyup event handler.
  245. */
  246. onKeyupDocument: function (e) {
  247. if (this.listView.shown && e.keyCode === 27) { // ESC
  248. this.listView.deactivate();
  249. this.$el.focus();
  250. }
  251. },
  252. /**
  253. * Remove all event handlers and the wrapper element.
  254. */
  255. destroy: function () {
  256. var $wrapper;
  257. this.$el.off('.textComplete');
  258. $(document).off('.' + this.id);
  259. if (this.listView) { this.listView.destroy(); }
  260. $wrapper = this.$el.parent();
  261. $wrapper.after(this.$el).remove();
  262. this.$el.data('textComplete', void 0);
  263. this.$el = null;
  264. },
  265. // Helper methods
  266. // ==============
  267. /**
  268. * Returns caret's relative coordinates from textarea's left top corner.
  269. */
  270. getCaretPosition: function () {
  271. // Browser native API does not provide the way to know the position of
  272. // caret in pixels, so that here we use a kind of hack to accomplish
  273. // the aim. First of all it puts a div element and completely copies
  274. // the textarea's style to the element, then it inserts the text and a
  275. // span element into the textarea.
  276. // Consequently, the span element's position is the thing what we want.
  277. if (this.el.selectionEnd === 0) return;
  278. var properties, css, $div, $span, position, dir;
  279. dir = this.$el.attr('dir') || this.$el.css('direction');
  280. properties = ['border-width', 'font-family', 'font-size', 'font-style',
  281. 'font-variant', 'font-weight', 'height', 'letter-spacing',
  282. 'word-spacing', 'line-height', 'text-decoration', 'text-align',
  283. 'width', 'padding-top', 'padding-right', 'padding-bottom',
  284. 'padding-left', 'margin-top', 'margin-right', 'margin-bottom',
  285. 'margin-left'
  286. ];
  287. css = $.extend({
  288. position: 'absolute',
  289. overflow: 'auto',
  290. 'white-space': 'pre-wrap',
  291. top: 0,
  292. left: -9999,
  293. direction: dir
  294. }, getStyles(this.$el, properties));
  295. $div = $('<div></div>').css(css).text(this.getTextFromHeadToCaret());
  296. $span = $('<span></span>').text('.').appendTo($div);
  297. this.$el.before($div);
  298. position = $span.position();
  299. position.top += $span.height() - this.$el.scrollTop();
  300. if (dir === 'rtl') { position.left -= this.listView.$el.width(); }
  301. $div.remove();
  302. return position;
  303. },
  304. getTextFromHeadToCaret: function () {
  305. var text, selectionEnd, range;
  306. selectionEnd = this.el.selectionEnd;
  307. if (typeof selectionEnd === 'number') {
  308. text = this.el.value.substring(0, selectionEnd);
  309. } else if (document.selection) {
  310. range = this.el.createTextRange();
  311. range.moveStart('character', 0);
  312. range.moveEnd('textedit');
  313. text = range.text;
  314. }
  315. return text;
  316. },
  317. /**
  318. * Parse the value of textarea and extract search query.
  319. */
  320. extractSearchQuery: function (text) {
  321. // If a search query found, it returns used strategy and the query
  322. // term. If the caret is currently in a code block or search query does
  323. // not found, it returns an empty array.
  324. var i, l, strategy, match;
  325. for (i = 0, l = this.strategies.length; i < l; i++) {
  326. strategy = this.strategies[i];
  327. match = text.match(strategy.match);
  328. if (match) { return [strategy, match[strategy.index]]; }
  329. }
  330. return [];
  331. },
  332. search: lock(function (free, searchQuery) {
  333. var term;
  334. this.strategy = searchQuery[0];
  335. term = searchQuery[1];
  336. this.strategy.search(term, this.searchCallbackFactory(free));
  337. })
  338. });
  339. /**
  340. * Completer's private functions
  341. */
  342. var wrapElement = function ($el) {
  343. return $el.wrap($baseWrapper.clone().css('display', $el.css('display')));
  344. };
  345. return Completer;
  346. })();
  347. /**
  348. * Dropdown menu manager class.
  349. */
  350. var ListView = (function () {
  351. function ListView($el, completer) {
  352. this.data = [];
  353. this.$el = $el;
  354. this.index = 0;
  355. this.completer = completer;
  356. this.$el.on('click.textComplete', 'li.textcomplete-item',
  357. $.proxy(this.onClick, this));
  358. }
  359. $.extend(ListView.prototype, {
  360. shown: false,
  361. render: function (data) {
  362. var html, i, l, index, val;
  363. html = '';
  364. for (i = 0, l = data.length; i < l; i++) {
  365. val = data[i];
  366. if (include(this.data, val)) continue;
  367. index = this.data.length;
  368. this.data.push(val);
  369. html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
  370. html += this.strategy.template(val);
  371. html += '</a></li>';
  372. if (this.data.length === this.strategy.maxCount) break;
  373. }
  374. this.$el.append(html);
  375. if (!this.data.length) {
  376. this.deactivate();
  377. } else {
  378. this.activateIndexedItem();
  379. }
  380. },
  381. clear: function () {
  382. this.data = [];
  383. this.$el.html('');
  384. this.index = 0;
  385. return this;
  386. },
  387. activateIndexedItem: function () {
  388. this.$el.find('.active').removeClass('active');
  389. this.getActiveItem().addClass('active');
  390. },
  391. getActiveItem: function () {
  392. return $(this.$el.children().get(this.index));
  393. },
  394. activate: function () {
  395. if (!this.shown) {
  396. this.$el.show();
  397. this.completer.$el.trigger('textComplete:show');
  398. this.shown = true;
  399. }
  400. return this;
  401. },
  402. deactivate: function () {
  403. if (this.shown) {
  404. this.$el.hide();
  405. this.completer.$el.trigger('textComplete:hide');
  406. this.shown = false;
  407. this.data = this.index = null;
  408. }
  409. return this;
  410. },
  411. setPosition: function (position) {
  412. this.$el.css(position);
  413. return this;
  414. },
  415. select: function (index) {
  416. var self = this;
  417. this.completer.onSelect(this.data[index]);
  418. // Deactive at next tick to allow other event handlers to know whether
  419. // the dropdown has been shown or not.
  420. setTimeout(function () { self.deactivate(); }, 0);
  421. },
  422. onKeydown: function (e) {
  423. if (!this.shown) return;
  424. if (e.keyCode === 38) { // UP
  425. e.preventDefault();
  426. if (this.index === 0) {
  427. this.index = this.data.length-1;
  428. } else {
  429. this.index -= 1;
  430. }
  431. this.activateIndexedItem();
  432. } else if (e.keyCode === 40) { // DOWN
  433. e.preventDefault();
  434. if (this.index === this.data.length - 1) {
  435. this.index = 0;
  436. } else {
  437. this.index += 1;
  438. }
  439. this.activateIndexedItem();
  440. } else if (e.keyCode === 13 || e.keyCode === 9) { // ENTER or TAB
  441. e.preventDefault();
  442. this.select(parseInt(this.getActiveItem().data('index'), 10));
  443. }
  444. },
  445. onClick: function (e) {
  446. var $e = $(e.target);
  447. e.originalEvent.keepTextCompleteDropdown = true;
  448. if (!$e.hasClass('textcomplete-item')) {
  449. $e = $e.parents('li.textcomplete-item');
  450. }
  451. this.select(parseInt($e.data('index'), 10));
  452. },
  453. destroy: function () {
  454. this.deactivate();
  455. this.$el.off('click.textComplete').remove();
  456. this.$el = null;
  457. }
  458. });
  459. return ListView;
  460. })();
  461. $.fn.textcomplete = function (strategies) {
  462. var i, l, strategy, dataKey;
  463. dataKey = 'textComplete';
  464. if (strategies === 'destroy') {
  465. return this.each(function () {
  466. var completer = $(this).data(dataKey);
  467. if (completer) { completer.destroy(); }
  468. });
  469. }
  470. for (i = 0, l = strategies.length; i < l; i++) {
  471. strategy = strategies[i];
  472. if (!strategy.template) {
  473. strategy.template = identity;
  474. }
  475. if (strategy.index == null) {
  476. strategy.index = 2;
  477. }
  478. if (strategy.cache) {
  479. strategy.search = memoize(strategy.search);
  480. }
  481. strategy.maxCount || (strategy.maxCount = 10);
  482. }
  483. return this.each(function () {
  484. var $this, completer;
  485. $this = $(this);
  486. completer = $this.data(dataKey);
  487. if (!completer) {
  488. completer = new Completer($this);
  489. $this.data(dataKey, completer);
  490. }
  491. completer.register(strategies);
  492. });
  493. };
  494. })(window.jQuery || window.Zepto);