/*! * jQuery.textcomplete.js * * Repositiory: https://github.com/yuku-t/jquery-textcomplete * License: MIT * Author: Yuku Takahashi */ ;(function ($) { 'use strict'; /** * Exclusive execution control utility. */ var lock = function (func) { var free, locked; free = function () { locked = false; }; return function () { var args; if (locked) return; locked = true; args = toArray(arguments); args.unshift(free); func.apply(this, args); }; }; /** * Convert arguments into a real array. */ var toArray = function (args) { var result; result = Array.prototype.slice.call(args); return result; }; /** * Get the styles of any element from property names. */ var getStyles = (function () { var color; color = $('
').css(['color']).color; if (typeof color !== 'undefined') { return function ($el, properties) { return $el.css(properties); }; } else { // for jQuery 1.8 or below return function ($el, properties) { var styles; styles = {}; $.each(properties, function (i, property) { styles[property] = $el.css(property); }); return styles; }; } })(); /** * Default template function. */ var identity = function (obj) { return obj; }; /** * Memoize a search function. */ var memoize = function (func) { var memo = {}; return function (term, callback) { if (memo[term]) { callback(memo[term]); } else { func.call(this, term, function (data) { memo[term] = (memo[term] || []).concat(data); callback.apply(null, arguments); }); } }; }; /** * Determine if the array contains a given value. */ var include = function (array, value) { var i, l; if (array.indexOf) return array.indexOf(value) != -1; for (i = 0, l = array.length; i < l; i++) { if (array[i] === value) return true; } return false; }; /** * Textarea manager class. */ var Completer = (function () { var html, css, $baseWrapper, $baseList, _id; html = { wrapper: '
', list: '' }; css = { wrapper: { position: 'relative' }, list: { position: 'absolute', top: 0, left: 0, zIndex: '100', display: 'none' } }; $baseWrapper = $(html.wrapper).css(css.wrapper); $baseList = $(html.list).css(css.list); _id = 0; function Completer($el) { var focus; this.el = $el.get(0); // textarea element focus = this.el === document.activeElement; // Cannot wrap $el at initialize method lazily due to Firefox's behavior. this.$el = wrapElement($el); // Focus is lost this.id = 'textComplete' + _id++; this.strategies = []; if (focus) { this.initialize(); this.$el.focus(); } else { this.$el.one('focus.textComplete', $.proxy(this.initialize, this)); } } /** * Completer's public methods */ $.extend(Completer.prototype, { /** * Prepare ListView and bind events. */ initialize: function () { var $list, globalEvents; $list = $baseList.clone(); this.listView = new ListView($list, this); this.$el .before($list) .on({ 'keyup.textComplete': $.proxy(this.onKeyup, this), 'keydown.textComplete': $.proxy(this.listView.onKeydown, this.listView) }); globalEvents = {}; globalEvents['click.' + this.id] = $.proxy(this.onClickDocument, this); globalEvents['keyup.' + this.id] = $.proxy(this.onKeyupDocument, this); $(document).on(globalEvents); }, /** * Register strategies to the completer. */ register: function (strategies) { this.strategies = this.strategies.concat(strategies); }, /** * Show autocomplete list next to the caret. */ renderList: function (data) { if (this.clearAtNext) { this.listView.clear(); this.clearAtNext = false; } if (data.length) { if (!this.listView.shown) { this.listView .setPosition(this.getCaretPosition()) .clear() .activate(); this.listView.strategy = this.strategy; } data = data.slice(0, this.strategy.maxCount); this.listView.render(data); } if (!this.listView.data.length && this.listView.shown) { this.listView.deactivate(); } }, searchCallbackFactory: function (free) { var self = this; return function (data, keep) { self.renderList(data); if (!keep) { // This is the last callback for this search. free(); self.clearAtNext = true; } }; }, /** * Keyup event handler. */ onKeyup: function (e) { var searchQuery, term; if (this.skipSearch(e)) { return; } searchQuery = this.extractSearchQuery(this.getTextFromHeadToCaret()); if (searchQuery.length) { term = searchQuery[1]; if (this.term === term) return; // Ignore shift-key or something. this.term = term; this.search(searchQuery); } else { this.term = null; this.listView.deactivate(); } }, /** * Suppress searching if it returns true. */ skipSearch: function (e) { if (this.skipNextKeyup) { this.skipNextKeyup = false; return true; } switch (e.keyCode) { case 40: case 38: return true; } }, onSelect: function (value) { var pre, post, newSubStr; pre = this.getTextFromHeadToCaret(); post = this.el.value.substring(this.el.selectionEnd); newSubStr = this.strategy.replace(value); if ($.isArray(newSubStr)) { post = newSubStr[1] + post; newSubStr = newSubStr[0]; } pre = pre.replace(this.strategy.match, newSubStr); this.$el.val(pre + post) .trigger('change') .trigger('textComplete:select', value); this.el.focus(); this.el.selectionStart = this.el.selectionEnd = pre.length; this.skipNextKeyup = true; }, /** * Global click event handler. */ onClickDocument: function (e) { if (e.originalEvent && !e.originalEvent.keepTextCompleteDropdown) { this.listView.deactivate(); } }, /** * Global keyup event handler. */ onKeyupDocument: function (e) { if (this.listView.shown && e.keyCode === 27) { // ESC this.listView.deactivate(); this.$el.focus(); } }, /** * Remove all event handlers and the wrapper element. */ destroy: function () { var $wrapper; this.$el.off('.textComplete'); $(document).off('.' + this.id); if (this.listView) { this.listView.destroy(); } $wrapper = this.$el.parent(); $wrapper.after(this.$el).remove(); this.$el.data('textComplete', void 0); this.$el = null; }, // Helper methods // ============== /** * Returns caret's relative coordinates from textarea's left top corner. */ getCaretPosition: function () { // Browser native API does not provide the way to know the position of // caret in pixels, so that here we use a kind of hack to accomplish // the aim. First of all it puts a div element and completely copies // the textarea's style to the element, then it inserts the text and a // span element into the textarea. // Consequently, the span element's position is the thing what we want. if (this.el.selectionEnd === 0) return; var properties, css, $div, $span, position, dir; dir = this.$el.attr('dir') || this.$el.css('direction'); properties = ['border-width', 'font-family', 'font-size', 'font-style', 'font-variant', 'font-weight', 'height', 'letter-spacing', 'word-spacing', 'line-height', 'text-decoration', 'text-align', 'width', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ]; css = $.extend({ position: 'absolute', overflow: 'auto', 'white-space': 'pre-wrap', top: 0, left: -9999, direction: dir }, getStyles(this.$el, properties)); $div = $('
').css(css).text(this.getTextFromHeadToCaret()); $span = $('').text('.').appendTo($div); this.$el.before($div); position = $span.position(); position.top += $span.height() - this.$el.scrollTop(); if (dir === 'rtl') { position.left -= this.listView.$el.width(); } $div.remove(); return position; }, getTextFromHeadToCaret: function () { var text, selectionEnd, range; selectionEnd = this.el.selectionEnd; if (typeof selectionEnd === 'number') { text = this.el.value.substring(0, selectionEnd); } else if (document.selection) { range = this.el.createTextRange(); range.moveStart('character', 0); range.moveEnd('textedit'); text = range.text; } return text; }, /** * Parse the value of textarea and extract search query. */ extractSearchQuery: function (text) { // If a search query found, it returns used strategy and the query // term. If the caret is currently in a code block or search query does // not found, it returns an empty array. var i, l, strategy, match; for (i = 0, l = this.strategies.length; i < l; i++) { strategy = this.strategies[i]; match = text.match(strategy.match); if (match) { return [strategy, match[strategy.index]]; } } return []; }, search: lock(function (free, searchQuery) { var term; this.strategy = searchQuery[0]; term = searchQuery[1]; this.strategy.search(term, this.searchCallbackFactory(free)); }) }); /** * Completer's private functions */ var wrapElement = function ($el) { return $el.wrap($baseWrapper.clone().css('display', $el.css('display'))); }; return Completer; })(); /** * Dropdown menu manager class. */ var ListView = (function () { function ListView($el, completer) { this.data = []; this.$el = $el; this.index = 0; this.completer = completer; this.$el.on('click.textComplete', 'li.textcomplete-item', $.proxy(this.onClick, this)); } $.extend(ListView.prototype, { shown: false, render: function (data) { var html, i, l, index, val; html = ''; for (i = 0, l = data.length; i < l; i++) { val = data[i]; if (include(this.data, val)) continue; index = this.data.length; this.data.push(val); html += '
  • '; html += this.strategy.template(val); html += '
  • '; if (this.data.length === this.strategy.maxCount) break; } this.$el.append(html); if (!this.data.length) { this.deactivate(); } else { this.activateIndexedItem(); } }, clear: function () { this.data = []; this.$el.html(''); this.index = 0; return this; }, activateIndexedItem: function () { this.$el.find('.active').removeClass('active'); this.getActiveItem().addClass('active'); }, getActiveItem: function () { return $(this.$el.children().get(this.index)); }, activate: function () { if (!this.shown) { this.$el.show(); this.completer.$el.trigger('textComplete:show'); this.shown = true; } return this; }, deactivate: function () { if (this.shown) { this.$el.hide(); this.completer.$el.trigger('textComplete:hide'); this.shown = false; this.data = this.index = null; } return this; }, setPosition: function (position) { this.$el.css(position); return this; }, select: function (index) { var self = this; this.completer.onSelect(this.data[index]); // Deactive at next tick to allow other event handlers to know whether // the dropdown has been shown or not. setTimeout(function () { self.deactivate(); }, 0); }, onKeydown: function (e) { if (!this.shown) return; if (e.keyCode === 38) { // UP e.preventDefault(); if (this.index === 0) { this.index = this.data.length-1; } else { this.index -= 1; } this.activateIndexedItem(); } else if (e.keyCode === 40) { // DOWN e.preventDefault(); if (this.index === this.data.length - 1) { this.index = 0; } else { this.index += 1; } this.activateIndexedItem(); } else if (e.keyCode === 13 || e.keyCode === 9) { // ENTER or TAB e.preventDefault(); this.select(parseInt(this.getActiveItem().data('index'), 10)); } }, onClick: function (e) { var $e = $(e.target); e.originalEvent.keepTextCompleteDropdown = true; if (!$e.hasClass('textcomplete-item')) { $e = $e.parents('li.textcomplete-item'); } this.select(parseInt($e.data('index'), 10)); }, destroy: function () { this.deactivate(); this.$el.off('click.textComplete').remove(); this.$el = null; } }); return ListView; })(); $.fn.textcomplete = function (strategies) { var i, l, strategy, dataKey; dataKey = 'textComplete'; if (strategies === 'destroy') { return this.each(function () { var completer = $(this).data(dataKey); if (completer) { completer.destroy(); } }); } for (i = 0, l = strategies.length; i < l; i++) { strategy = strategies[i]; if (!strategy.template) { strategy.template = identity; } if (strategy.index == null) { strategy.index = 2; } if (strategy.cache) { strategy.search = memoize(strategy.search); } strategy.maxCount || (strategy.maxCount = 10); } return this.each(function () { var $this, completer; $this = $(this); completer = $this.data(dataKey); if (!completer) { completer = new Completer($this); $this.data(dataKey, completer); } completer.register(strategies); }); }; })(window.jQuery || window.Zepto);