Skip to content

Instantly share code, notes, and snippets.

@shawn-simon
Created May 6, 2014 20:33
Show Gist options
  • Save shawn-simon/c45152770da94d0c6d83 to your computer and use it in GitHub Desktop.
Save shawn-simon/c45152770da94d0c6d83 to your computer and use it in GitHub Desktop.
jquery-textcomplete.updated.js
/*!
* 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, queuedArgsToReplay;
free = function () { locked = false; };
return function () {
var args = toArray(arguments);
if (locked) {
// Keep a copy of this argument list to replay later.
// OK to overwrite a previous value because we only replay the last one.
queuedArgsToReplay = args;
return;
}
locked = true;
var that = this;
args.unshift(function replayOrFree() {
if (queuedArgsToReplay) {
// Other request(s) arrived while we were locked.
// Now that the lock is becoming available, replay
// the latest such request, then call back here to
// unlock (or replay another request that arrived
// while this one was in flight).
var replayArgs = queuedArgsToReplay;
queuedArgsToReplay = undefined;
replayArgs.unshift(replayOrFree);
func.apply(that, replayArgs);
} else {
locked = false;
}
});
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 = $('<div></div>').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, $baseWrapperBody, $baseList, _id;
html = {
wrapper: '<div class="textcomplete-wrapper"></div>',
list: '<ul class="dropdown-menu"></ul>'
};
css = {
wrapper: {
position: 'relative'
},
bodyWrapper: {
position: 'absolute',
top: 0,
left: 0
},
// Removed the 'top' property to support the placement: 'top' option
list: {
position: 'absolute',
left: 0,
zIndex: '10050',
display: 'none'
}
};
$baseWrapper = $(html.wrapper).css(css.wrapper);
$baseWrapperBody = $(html.wrapper).css(css.bodyWrapper);
$baseList = $(html.list).css(css.list);
_id = 0;
function Completer($el, options) {
this.options = options || this.options || {};
var focus;
this.el = $el.get(0); // textarea element
focus = this.el === this.el.ownerDocument.activeElement;
// Cannot wrap $el at initialize method lazily due to Firefox's behavior.
this.$el = $el;
this.$wrapper = wrapElement($el, this.options); // 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.$wrapper.prepend($list);
this.$el
.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);
$(this.el.ownerDocument).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) {
this.listView.strategy = this.strategy;
if (!this.listView.shown) {
this.listView
.setPosition(this.getCaretPosition())
.clear()
.activate();
}
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 (e.keyCode == 16) return true;
switch (e.keyCode) {
case 40: // DOWN
case 38: // UP
return true;
}
if (e.ctrlKey) switch (e.keyCode) {
case 78: // Ctrl-N
case 80: // Ctrl-P
return true;
}
},
onSelect: function (value) {
var pre, post, newSubStr, sel, range, selection;
pre = this.getTextFromHeadToCaret();
if (this.el.contentEditable == 'true') {
sel = this.el.ownerDocument.getSelection();
range = sel.getRangeAt(0);
selection = range.cloneRange();
selection.selectNodeContents(range.startContainer);
var content = selection.toString();
post = content.substring(range.startOffset);
} else {
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);
if (this.el.contentEditable == 'true') {
range.selectNodeContents(range.startContainer);
range.deleteContents();
var node = this.el.ownerDocument.createTextNode(pre + post);
range.insertNode(node);
range.setStart(node, pre.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
this.$el.val(pre + post);
this.el.selectionStart = this.el.selectionEnd = pre.length;
}
this.$el.trigger('change')
.trigger('textComplete:select', value);
this.el.focus();
},
/**
* 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(); }
if (this.options.appendToBody)
{
this.$wrapper.remove()
}
else
{
$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.
var properties, css, $div, $span, position, dir, scrollbar;
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', 'border-style', 'box-sizing', 'tab-size'
];
scrollbar = this.$el[0].scrollHeight > this.$el[0].offsetHeight;
css = $.extend({
position: 'absolute',
overflow: scrollbar ? 'scroll' : 'auto',
'white-space': 'pre-wrap',
'word-wrap' : 'break-word',
top: 0,
left: -9999,
direction: dir
}, getStyles(this.$el, properties));
$div = $('<div></div>').css(css).text(this.getTextFromHeadToCaret());
$span = $('<span></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();
if (this.el.contentEditable == 'true') {
var range = this.el.ownerDocument.getSelection().getRangeAt(0);
var node = this.el.ownerDocument.createElement('span');
range.insertNode(node);
position = $(node).position();
/* hack for browers who measure scrollTop on html element instead of body inside iframes. */
var scrollPosition = this.$el.scrollTop();
if (this.el.tagName == 'BODY' && scrollPosition == 0)
{
scrollPosition = this.$el.parent().scrollTop();
}
position.top += $(node).height() - scrollPosition;
}
if (this.options.appendToBody)
{
var offsetElement = this.options.offsetElement || this.$el;
position.top += offsetElement.offset().top;
position.left += offsetElement.offset().left;
}
return position;
},
getTextFromHeadToCaret: function () {
var text, selectionEnd, range;
if (this.el.contentEditable == 'true') {
if (window.getSelection) {
// IE9+ and non-IE
var range = this.el.ownerDocument.getSelection().getRangeAt(0);
var selection = range.cloneRange();
selection.selectNodeContents(range.startContainer);
text = selection.toString().substring(0, range.startOffset);
}
} else {
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) {
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, options) {
var wrapper;
if (options.appendToBody)
{
wrapper = $baseWrapperBody.clone().css('display', $el.css('display'))
$('body').append(wrapper);
}
else
{
wrapper = $baseWrapper.clone().css('display', $el.css('display'))
$el.wrap(wrapper);
wrapper = $el.parent();
}
return wrapper;
};
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 = '';
if(this.strategy.header) {
html += '<li class="textcomplete-header">' + this.strategy.header + '</li>';
}
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 += '<li class="textcomplete-item" data-index="' + index + '"><a>';
html += this.strategy.template(val);
html += '</a></li>';
if (this.data.length === this.strategy.maxCount) break;
}
if(this.strategy.footer) {
html += '<li class="textcomplete-footer">' + this.strategy.footer + '</li>';
}
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');
var item = this.getActiveItem();
item.addClass('active');
var height = item.height();
var parentHeight = this.$el.height();
var scrollTop = this.$el.scrollTop();
var position = item.position().top;
if (position < 0) this.$el.scrollTop(scrollTop + position)
if ((parentHeight) < (position + height)) this.$el.scrollTop(scrollTop + position - height)
// this logic could be a little bit better, but works well enough.
},
getActiveItem: function () {
return $(this.$el.children('.textcomplete-item').get(this.index));
},
activate: function () {
if (!this.shown) {
this.$el.show();
this.$el.scrollTop(0)
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) {
var fontSize;
// If the strategy has the 'placement' option set to 'top', move the
// position above the element
if(this.strategy.placement === 'top') {
// Move it to be in line with the match character
fontSize = parseInt(this.$el.css('font-size'));
// Overwrite the position object to set the 'bottom' property instead of the top.
position = {
top: 'auto',
bottom: this.$el.parent().height() - position.top + fontSize,
left: position.left
};
} else {
// Overwrite 'bottom' property because once `placement: 'top'`
// strategy is shown, $el keeps the property.
position.bottom = 'auto';
}
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 || (e.ctrlKey && e.keyCode === 80)) { // UP, or Ctrl-P
e.preventDefault();
if (this.index === 0) {
this.index = this.data.length-1;
} else {
this.index -= 1;
}
this.activateIndexedItem();
} else if (e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78)) { // DOWN, or Ctrl-N
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();
e.stopPropagation();
this.select(parseInt(this.getActiveItem().data('index'), 10));
return false;
}
},
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, options) {
var i, l, strategy, dataKey;
dataKey = 'textComplete';
if (strategies === 'destroy') {
return this.each(function (i, j) {
try {
var completer = $(j).data(dataKey);
if (completer) { completer.destroy(); }
}
catch (e) {
// IE flips out for no reason
}
});
}
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, options);
$this.data(dataKey, completer);
}
completer.register(strategies);
});
};
})(window.jQuery || window.Zepto);
@temuri416
Copy link

Thanks for this. Is the instantiation any different from the orginal? Are there extra options or CSS styles?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment