Merge branch 'hotfix/21.56.9' into master
[gitter.git] / public / repo / jquery-textcomplete / jquery.textcomplete.js
blob5f493e70ffeeb578a051368735ee55c3026a3ab2
1 var $ = require('jquery');
3 /*!
4 * jQuery.textcomplete.js
6 * Repositiory: https://github.com/yuku-t/jquery-textcomplete
7 * License: MIT
8 * Author: Yuku Takahashi
9 */
11 (function($) {
12 'use strict';
14 /**
15 * Convert arguments into a real array.
17 var toArray = function(args) {
18 var result;
19 result = Array.prototype.slice.call(args);
20 return result;
23 /**
24 * Bind the func to the context.
26 var bind = function(func, context) {
27 if (func.bind) {
28 // Use native Function#bind if it's available.
29 return func.bind(context);
30 } else {
31 return function() {
32 func.apply(context, arguments);
37 /**
38 * Get the styles of any element from property names.
40 var getStyles = (function() {
41 var color;
42 color = $('<div></div>').css(['color']).color;
43 if (typeof color !== 'undefined') {
44 return function($el, properties) {
45 return $el.css(properties);
47 } else {
48 // for jQuery 1.8 or below
49 return function($el, properties) {
50 var styles;
51 styles = {};
52 $.each(properties, function(i, property) {
53 styles[property] = $el.css(property);
54 });
55 return styles;
58 })();
60 /**
61 * Default template function.
63 var identity = function(obj) {
64 return obj;
67 /**
68 * Memoize a search function.
70 var memoize = function(func) {
71 var memo = {};
72 return function(term, callback) {
73 if (memo[term]) {
74 callback(memo[term]);
75 } else {
76 func.call(this, term, function(data) {
77 memo[term] = (memo[term] || []).concat(data);
78 callback.apply(null, arguments);
79 });
84 /**
85 * Determine if the array contains a given value.
87 var include = function(array, value) {
88 var i, l;
89 if (array.indexOf) return array.indexOf(value) != -1;
90 for (i = 0, l = array.length; i < l; i++) {
91 if (array[i] === value) return true;
93 return false;
96 /**
97 * Textarea manager class.
99 var Completer = (function() {
100 var html, css, $baseWrapper, $baseList;
102 html = {
103 wrapper: '<div class="textcomplete-wrapper"></div>',
104 list: '<ul class="dropdown-menu"></ul>'
106 css = {
107 wrapper: {
108 position: 'relative'
110 list: {
111 position: 'absolute',
112 top: 'initial',
113 left: 0,
114 zIndex: '100',
115 display: 'none'
118 $baseWrapper = $(html.wrapper).css(css.wrapper);
119 $baseList = $(html.list).css(css.list);
121 function Completer($el, strategies) {
122 var $wrapper, $list, focused;
123 $list = $baseList.clone();
124 this.el = $el.get(0); // textarea element
125 this.$el = $el;
126 $wrapper = prepareWrapper(this.$el);
128 // Refocus the textarea if it is being focused
129 focused = this.el === document.activeElement;
130 this.$el.wrap($wrapper).before($list);
131 if (focused) {
132 this.el.focus();
135 this.listView = new ListView($list, this);
136 this.strategies = strategies;
137 this.$el.on('keyup', bind(this.onKeyup, this));
138 this.$el.on('keydown', bind(this.listView.onKeydown, this.listView));
140 // Global click event handler
141 $(document).on(
142 'click',
143 bind(function(e) {
144 if (e.originalEvent && !e.originalEvent.keepTextCompleteDropdown) {
145 this.listView.deactivate();
147 }, this)
152 * Completer's public methods
154 $.extend(Completer.prototype, {
156 * Show autocomplete list next to the caret.
158 renderList: function(data) {
159 if (this.clearAtNext) {
160 this.listView.clear();
161 this.clearAtNext = false;
163 if (data.length) {
164 if (!this.listView.shown) {
165 this.listView
166 .setPosition(this.getCaretPosition())
167 .clear()
168 .activate();
169 this.listView.strategy = this.strategy;
171 data = data.slice(0, this.strategy.maxCount);
172 this.listView.render(data);
175 if ((!this.listView.data || !this.listView.data.length) && this.listView.shown) {
176 this.listView.deactivate();
180 searchCallbackFactory: function(term) {
181 var self = this;
182 return function(data, keep) {
183 // ignore old calbacks
184 if (term != self.term) return;
186 self.renderList(data);
187 if (!keep) {
188 // This is the last callback for this search.
189 self.clearAtNext = true;
195 * Keyup event handler.
197 onKeyup: function(e) {
198 var searchQuery, term;
200 searchQuery = this.extractSearchQuery(this.getTextFromHeadToCaret());
201 if (searchQuery.length) {
202 term = searchQuery[1];
203 if (this.term === term) return; // Ignore shift-key or something.
204 this.term = term;
205 this.search(searchQuery);
206 } else {
207 this.term = null;
208 this.listView.deactivate();
212 onSelect: function(value) {
213 var pre, post, newSubStr;
214 pre = this.getTextFromHeadToCaret();
215 post = this.el.value.substring(this.el.selectionEnd);
217 newSubStr = this.strategy.replace(value);
218 if ($.isArray(newSubStr)) {
219 post = newSubStr[1] + post;
220 newSubStr = newSubStr[0];
222 pre = pre.replace(this.strategy.match, newSubStr);
223 this.$el.val(pre + post);
224 this.el.focus();
225 this.el.selectionStart = this.el.selectionEnd = pre.length;
228 // Helper methods
229 // ==============
232 * Returns caret's relative coordinates from textarea's left top corner.
234 getCaretPosition: function() {
235 // Browser native API does not provide the way to know the position of
236 // caret in pixels, so that here we use a kind of hack to accomplish
237 // the aim. First of all it puts a div element and completely copies
238 // the textarea's style to the element, then it inserts the text and a
239 // span element into the textarea.
240 // Consequently, the span element's position is the thing what we want.
242 if (this.el.selectionEnd === 0) return;
243 var properties, css, $div, $span, position;
245 properties = [
246 'border-width',
247 'font-family',
248 'font-size',
249 'font-style',
250 'font-variant',
251 'font-weight',
252 'height',
253 'letter-spacing',
254 'word-spacing',
255 'line-height',
256 'text-decoration',
257 'text-align',
258 'width',
259 'padding-top',
260 'padding-right',
261 'padding-bottom',
262 'padding-left',
263 'margin-top',
264 'margin-right',
265 'margin-bottom',
266 'margin-left'
268 css = $.extend(
270 position: 'absolute',
271 overflow: 'auto',
272 'white-space': 'pre-wrap',
273 top: 0,
274 left: -9999
276 getStyles(this.$el, properties)
279 $div = $('<div></div>')
280 .css(css)
281 .text(this.getTextFromHeadToCaret());
282 $span = $('<span></span>')
283 .text('&nbsp;')
284 .appendTo($div);
285 this.$el.before($div);
286 position = $span.position();
287 position.bottom = $div.height() - position.top;
288 $div.remove();
289 return position;
292 getTextFromHeadToCaret: function() {
293 var text, selectionEnd, range;
294 selectionEnd = this.el.selectionEnd;
295 if (typeof selectionEnd === 'number') {
296 text = this.el.value.substring(0, selectionEnd);
297 } else if (document.selection) {
298 range = this.el.createTextRange();
299 range.moveStart('character', 0);
300 range.moveEnd('textedit');
301 text = range.text;
303 return text;
307 * Parse the value of textarea and extract search query.
309 extractSearchQuery: function(text) {
310 // If a search query found, it returns used strategy and the query
311 // term. If the caret is currently in a code block or search query does
312 // not found, it returns an empty array.
314 var i, l, strategy, match;
315 for (i = 0, l = this.strategies.length; i < l; i++) {
316 strategy = this.strategies[i];
317 match = text.match(strategy.match);
318 if (match) {
319 return [strategy, match[strategy.index]];
322 return [];
325 search: function(searchQuery) {
326 var term, strategy;
327 this.strategy = searchQuery[0];
328 term = searchQuery[1];
329 this.strategy.search(term, this.searchCallbackFactory(term));
334 * Completer's private functions
336 var prepareWrapper = function($el) {
337 return $baseWrapper.clone().css('display', $el.css('display'));
340 return Completer;
341 })();
344 * Dropdown menu manager class.
346 var ListView = (function() {
347 function ListView($el, completer) {
348 this.data = [];
349 this.$el = $el;
350 this.index = 0;
351 this.completer = completer;
353 this.$el.on('click', 'li.textcomplete-item', bind(this.onClick, this));
356 $.extend(ListView.prototype, {
357 shown: false,
359 render: function(data) {
360 var html, i, l, index, val;
362 html = '';
363 for (i = 0, l = data.length; i < l; i++) {
364 val = data[i];
365 if (include(this.data, val)) continue;
366 index = this.data.length;
367 this.data.push(val);
368 html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
369 html += this.strategy.template(val);
370 html += '</a></li>';
371 if (this.data.length === this.strategy.maxCount) break;
373 this.$el.append(html);
374 if (!this.data.length) {
375 this.deactivate();
376 } else {
377 this.activateIndexedItem();
381 clear: function() {
382 this.data = [];
383 this.$el.html('');
384 this.index = 0;
385 return this;
388 activateIndexedItem: function() {
389 var $item;
390 this.$el.find('.active').removeClass('active');
391 this.getActiveItem().addClass('active');
394 getActiveItem: function() {
395 return $(this.$el.children().get(this.index));
398 activate: function() {
399 if (!this.shown) {
400 this.$el.show();
401 this.completer.$el.trigger('textComplete:show');
402 this.shown = true;
404 return this;
407 deactivate: function() {
408 if (this.shown) {
409 this.$el.hide();
410 this.completer.$el.trigger('textComplete:hide');
411 this.shown = false;
412 this.data = this.index = null;
414 return this;
417 setPosition: function(position) {
418 this.$el.css({
419 left: position.left,
420 top: 'initial',
421 bottom: position.bottom
423 return this;
426 select: function(index) {
427 this.completer.onSelect(this.data[index]);
428 this.deactivate();
431 onKeydown: function(e) {
432 var $item;
433 if (!this.shown) return;
434 if (e.keyCode === 27) {
435 // ESC
436 this.deactivate();
437 } else if (e.keyCode === 38) {
438 // UP
439 e.preventDefault();
440 if (this.index === 0) {
441 this.index = this.data.length - 1;
442 } else {
443 this.index -= 1;
445 this.activateIndexedItem();
446 } else if (e.keyCode === 40) {
447 // DOWN
448 e.preventDefault();
449 if (this.index === this.data.length - 1) {
450 this.index = 0;
451 } else {
452 this.index += 1;
454 this.activateIndexedItem();
455 } else if (e.keyCode === 13 || e.keyCode === 9) {
456 // ENTER or TAB
457 e.preventDefault();
458 this.select(parseInt(this.getActiveItem().data('index')));
462 onClick: function(e) {
463 var $e = $(e.target);
464 e.originalEvent.keepTextCompleteDropdown = true;
465 if (!$e.hasClass('textcomplete-item')) {
466 $e = $e.parents('li.textcomplete-item');
468 this.select(parseInt($e.data('index')));
472 return ListView;
473 })();
475 $.fn.textcomplete = function(strategies) {
476 var i, l, strategy;
477 for (i = 0, l = strategies.length; i < l; i++) {
478 strategy = strategies[i];
479 if (!strategy.template) {
480 strategy.template = identity;
482 if (strategy.index == null) {
483 strategy.index = 2;
485 if (strategy.cache) {
486 strategy.search = memoize(strategy.search);
488 strategy.maxCount || (strategy.maxCount = 10);
490 new Completer(this, strategies);
492 return this;
494 })($);
496 module.exports = $;