/*! * jQuery.textcomplete * * Repository: https://github.com/yuku-t/jquery-textcomplete * License: MIT (https://github.com/yuku-t/jquery-textcomplete/blob/master/LICENSE) * Author: Yuku Takahashi */ if (typeof jQuery === 'undefined') { throw new Error('jQuery.textcomplete requires jQuery'); } +function ($) { 'use strict'; var warn = function (message) { if (console.warn) { console.warn(message); } }; $.fn.textcomplete = function (strategies, option) { var args = Array.prototype.slice.call(arguments); return this.each(function () { var $this = $(this); var completer = $this.data('textComplete'); if (!completer) { completer = new $.fn.textcomplete.Completer(this, option || {}); $this.data('textComplete', completer); } if (typeof strategies === 'string') { if (!completer) return; args.shift() completer[strategies].apply(completer, args); if (strategies === 'destroy') { $this.removeData('textComplete'); } } else { // For backward compatibility. // TODO: Remove at v0.4 $.each(strategies, function (obj) { $.each(['header', 'footer', 'placement', 'maxCount'], function (name) { if (obj[name]) { completer.option[name] = obj[name]; warn(name + 'as a strategy param is deprecated. Use option.'); delete obj[name]; } }); }); completer.register($.fn.textcomplete.Strategy.parse(strategies)); } }); }; }(jQuery); +function ($) { 'use strict'; // Exclusive execution control utility. // // func - The function to be locked. It is executed with a function named // `free` as the first argument. Once it is called, additional // execution are ignored until the free is invoked. Then the last // ignored execution will be replayed immediately. // // Examples // // var lockedFunc = lock(function (free) { // setTimeout(function { free(); }, 1000); // It will be free in 1 sec. // console.log('Hello, world'); // }); // lockedFunc(); // => 'Hello, world' // lockedFunc(); // none // lockedFunc(); // none // // 1 sec past then // // => 'Hello, world' // lockedFunc(); // => 'Hello, world' // lockedFunc(); // none // // Returns a wrapped function. var lock = function (func) { var locked, queuedArgsToReplay; return function () { // Convert arguments into a real array. var args = Array.prototype.slice.call(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 self = 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(self, replayArgs); } else { locked = false; } }); func.apply(this, args); }; }; var isString = function (obj) { return Object.prototype.toString.call(obj) === '[object String]'; }; var uniqueId = 0; function Completer(element, option) { this.$el = $(element); this.id = 'textcomplete' + uniqueId++; this.strategies = []; this.views = []; this.option = $.extend({}, Completer._getDefaults(), option); if (!this.$el.is('input[type=text]') && !this.$el.is('textarea') && !element.isContentEditable && element.contentEditable != 'true') { throw new Error('textcomplete must be called on a Textarea or a ContentEditable.'); } if (element === document.activeElement) { // element has already been focused. Initialize view objects immediately. this.initialize() } else { // Initialize view objects lazily. var self = this; this.$el.one('focus.' + this.id, function () { self.initialize(); }); } } Completer._getDefaults = function () { if (!Completer.DEFAULTS) { Completer.DEFAULTS = { appendTo: $('body'), zIndex: '100' }; } return Completer.DEFAULTS; } $.extend(Completer.prototype, { // Public properties // ----------------- id: null, option: null, strategies: null, adapter: null, dropdown: null, $el: null, // Public methods // -------------- initialize: function () { var element = this.$el.get(0); // Initialize view objects. this.dropdown = new $.fn.textcomplete.Dropdown(element, this, this.option); this.dropdown.upSideDown = false; var Adapter, viewName; if (this.option.adapter) { Adapter = this.option.adapter; } else { if (this.$el.is('textarea') || this.$el.is('input[type=text]')) { viewName = typeof element.selectionEnd === 'number' ? 'Textarea' : 'IETextarea'; } else { viewName = 'ContentEditable'; } Adapter = $.fn.textcomplete[viewName]; } this.adapter = new Adapter(element, this, this.option); }, destroy: function () { this.$el.off('.' + this.id); if (this.adapter) { this.adapter.destroy(); } if (this.dropdown) { this.dropdown.destroy(); } this.$el = this.adapter = this.dropdown = null; }, // Invoke textcomplete. trigger: function (text, skipUnchangedTerm) { if (!this.dropdown) { this.initialize(); } text != null || (text = this.adapter.getTextFromHeadToCaret()); var searchQuery = this._extractSearchQuery(text); if (searchQuery.length) { var term = searchQuery[1]; // Ignore shift-key, ctrl-key and so on. if (skipUnchangedTerm && this._term === term) { return; } this._term = term; this.fire('textComplete:beforeSearch'); this._search.apply(this, searchQuery); this.fire('textComplete:afterSearch'); } else { this._term = null; this.dropdown.deactivate(); } }, fire: function (eventName) { var args = Array.prototype.slice.call(arguments, 1); this.$el.trigger(eventName, args); return this; }, register: function (strategies) { Array.prototype.push.apply(this.strategies, strategies); }, // Insert the value into adapter view. It is called when the dropdown is clicked // or selected. // // value - The selected element of the array callbacked from search func. // strategy - The Strategy object. select: function (value, strategy) { this.adapter.select(value, strategy); this.fire('change').fire('textComplete:select', value, strategy); this.adapter.focus(); }, // Private properties // ------------------ _clearAtNext: true, _term: null, // Private methods // --------------- // Parse the given text and extract the first matching strategy. // // Returns an array including the strategy, the query term and the match // object if the text matches an strategy; otherwise returns an empty array. _extractSearchQuery: function (text) { for (var i = 0; i < this.strategies.length; i++) { var strategy = this.strategies[i]; var context = strategy.context(text); if (context || context === '') { if (isString(context)) { text = context; } var cursor = editor.getCursor(); var line = editor.getLine(cursor.line); var linematch = line.match(strategy.match); if(linematch) { text = line.slice(0, cursor.ch); var textmatch = text.match(strategy.match); if (textmatch) { return [strategy, textmatch[strategy.index], textmatch]; } } } } return [] }, // Call the search method of selected strategy.. _search: lock(function (free, strategy, term, match) { var self = this; strategy.search(term, function (data, stillSearching) { if (!self.dropdown.shown) { self.dropdown.activate(); self.dropdown.setPosition(self.adapter.getCaretPosition()); } if (self._clearAtNext) { // The first callback in the current lock. self.dropdown.clear(); self._clearAtNext = false; } self.dropdown.render(self._zip(data, strategy)); if (!stillSearching) { // The last callback in the current lock. free(); self._clearAtNext = true; // Call dropdown.clear at the next time. } }, match); }), // Build a parameter for Dropdown#render. // // Examples // // this._zip(['a', 'b'], 's'); // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }] _zip: function (data, strategy) { return $.map(data, function (value) { return { value: value, strategy: strategy }; }); } }); $.fn.textcomplete.Completer = Completer; }(jQuery); +function ($) { 'use strict'; var include = function (zippedData, datum) { var i, elem; var idProperty = datum.strategy.idProperty for (i = 0; i < zippedData.length; i++) { elem = zippedData[i]; if (elem.strategy !== datum.strategy) continue; if (idProperty) { if (elem.value[idProperty] === datum.value[idProperty]) return true; } else { if (elem.value === datum.value) return true; } } return false; }; var dropdownViews = {}; /* $(document).on('mousedown', function (e) { var id = e.originalEvent && e.originalEvent.keepTextCompleteDropdown; $.each(dropdownViews, function (key, view) { if (key !== id) { view.deactivate(); } }); }); */ // Dropdown view // ============= // Construct Dropdown object. // // element - Textarea or contenteditable element. function Dropdown(element, completer, option) { this.$el = Dropdown.findOrCreateElement(option); this.completer = completer; this.id = completer.id + 'dropdown'; this._data = []; // zipped data. this.$inputEl = $(element); this.option = option; this.tap = false; // Override setPosition method. if (option.listPosition) { this.setPosition = option.listPosition; } if (option.height) { this.$el.height(option.height); } var self = this; $.each(['maxCount', 'placement', 'footer', 'header', 'className'], function (_i, name) { if (option[name] != null) { self[name] = option[name]; } }); this._bindEvents(element); dropdownViews[this.id] = this; } $.extend(Dropdown, { // Class methods // ------------- findOrCreateElement: function (option) { var $parent = option.appendTo; if (!($parent instanceof $)) { $parent = $($parent); } var $el = $parent.children('.dropdown-menu') if (!$el.length) { $el = $('
').css({ display: 'none', left: 0, position: 'absolute', zIndex: option.zIndex }).appendTo($parent); } return $el; } }); $.extend(Dropdown.prototype, { // Public properties // ----------------- $el: null, // jQuery object of ul.dropdown-menu element. $inputEl: null, // jQuery object of target textarea. completer: null, footer: null, header: null, id: null, maxCount: 10, placement: '', shown: false, data: [], // Shown zipped data. className: '', // Public methods // -------------- destroy: function () { // Don't remove $el because it may be shared by several textcompletes. this.deactivate(); this.$el.off('.' + this.id); this.$inputEl.off('.' + this.id); this.clear(); this.$el = this.$inputEl = this.completer = null; delete dropdownViews[this.id] }, render: function (zippedData) { var contentsHtml = this._buildContents(zippedData); var unzippedData = $.map(this.data, function (d) { return d.value; }); if (this.data.length) { this._renderHeader(unzippedData); this._renderFooter(unzippedData); if (contentsHtml) { this._renderContents(contentsHtml); this._activateIndexedItem(); } this._setScroll(); } else if (this.shown) { this.deactivate(); } }, setPosition: function (position) { this.$el.css(this._applyPlacement(position)); // Make the dropdown fixed if the input is also fixed // This can't be done during init, as textcomplete may be used on multiple elements on the same page // Because the same dropdown is reused behind the scenes, we need to recheck every time the dropdown is showed var position = 'absolute'; // Check if input or one of its parents has positioning we need to care about this.$inputEl.add(this.$inputEl.parents()).each(function() { if($(this).css('position') === 'absolute') // The element has absolute positioning, so it's all OK return false; if($(this).css('position') === 'fixed') { position = 'fixed'; return false; } }); this.$el.css({ position: position }); // Update positioning return this; }, clear: function () { this.$el.html(''); this.data = []; this._index = 0; this._$header = this._$footer = null; }, activate: function () { if (!this.shown) { this.clear(); this.$el.show(); if (this.className) { this.$el.addClass(this.className); } this.completer.fire('textComplete:show'); this.shown = true; } return this; }, deactivate: function () { if (this.shown) { this.$el.hide(); if (this.className) { this.$el.removeClass(this.className); } this.completer.fire('textComplete:hide'); this.shown = false; } return this; }, isUp: function (e) { return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P }, isDown: function (e) { return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N }, isEnter: function (e) { var modifiers = e.ctrlKey || e.altKey || e.metaKey || e.shiftKey; return !modifiers && (e.keyCode === 13 || e.keyCode === 9 || (this.option.completeOnSpace === true && e.keyCode === 32)) // ENTER, TAB }, isPageup: function (e) { return e.keyCode === 33; // PAGEUP }, isPagedown: function (e) { return e.keyCode === 34; // PAGEDOWN }, isEscape: function (e) { return e.keyCode === 27; // ESCAPE }, // Private properties // ------------------ _data: null, // Currently shown zipped data. _index: null, _$header: null, _$footer: null, // Private methods // --------------- _bindEvents: function () { this.$inputEl.on('blur', $.proxy(this.deactivate, this)); this.$el.on('touchstart.' + this.id, '.textcomplete-item', $.proxy(function(e) { this.tap = true; }, this)); this.$el.on('touchmove.' + this.id, '.textcomplete-item', $.proxy(function(e) { this.tap = false; }, this)); this.$el.on('touchend.' + this.id, '.textcomplete-item', $.proxy(function(e) { if(e.cancelable && this.tap) { this._onClick(e); } }, this)); this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this)); this.$el.on('mouseover.' + this.id, '.textcomplete-item', $.proxy(this._onMouseover, this)); this.$inputEl.on('keydown.' + this.id, $.proxy(this._onKeydown, this)); }, _onClick: function (e) { var $el = $(e.target); e.stopPropagation(); e.preventDefault(); e.originalEvent.keepTextCompleteDropdown = this.id; if (!$el.hasClass('textcomplete-item')) { $el = $el.closest('.textcomplete-item'); } var datum = this.data[parseInt($el.data('index'), 10)]; this.completer.select(datum.value, datum.strategy); var self = this; // Deactive at next tick to allow other event handlers to know whether // the dropdown has been shown or not. setTimeout(function () { self.deactivate(); }, 0); }, // Activate hovered item. _onMouseover: function (e) { var $el = $(e.target); e.preventDefault(); if (!$el.hasClass('textcomplete-item')) { $el = $el.closest('.textcomplete-item'); } this._index = parseInt($el.data('index'), 10); this._activateIndexedItem(); }, _onKeydown: function (e) { if (!this.shown) { return; } if (this.isUp(e)) { e.preventDefault(); if(this.upSideDown) this._down(); else this._up(); } else if (this.isDown(e)) { e.preventDefault(); if(this.upSideDown) this._up(); else this._down(); } else if (this.isEnter(e)) { e.preventDefault(); this._enter(); } else if (this.isPageup(e)) { e.preventDefault(); this._pageup(); } else if (this.isPagedown(e)) { e.preventDefault(); this._pagedown(); } else if (this.isEscape(e)) { e.preventDefault(); this.deactivate(); } }, _up: function () { if (this._index === 0) { this._index = this.data.length - 1; } else { this._index -= 1; } this._activateIndexedItem(); this._setScroll(); }, _down: function () { if (this._index === this.data.length - 1) { this._index = 0; } else { this._index += 1; } this._activateIndexedItem(); this._setScroll(); }, _enter: function () { var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)]; this.completer.select(datum.value, datum.strategy); this.deactivate(); }, _pageup: function () { var target = 0; var threshold = this._getActiveElement().position().top - this.$el.innerHeight(); this.$el.children().each(function (i) { if ($(this).position().top + $(this).outerHeight() > threshold) { target = i; return false; } }); this._index = target; this._activateIndexedItem(); this._setScroll(); }, _pagedown: function () { var target = this.data.length - 1; var threshold = this._getActiveElement().position().top + this.$el.innerHeight(); this.$el.children().each(function (i) { if ($(this).position().top > threshold) { target = i; return false } }); this._index = target; this._activateIndexedItem(); this._setScroll(); }, _activateIndexedItem: function () { this.$el.find('.textcomplete-item.active').removeClass('active'); this._getActiveElement().addClass('active'); }, _getActiveElement: function () { return this.$el.children('.textcomplete-item[data-index=' + this._index + ']'); }, _setScroll: function () { var $activeEl = this._getActiveElement(); var itemTop = $activeEl.position().top; var itemHeight = $activeEl.outerHeight(); var visibleHeight = this.$el.innerHeight(); var visibleTop = this.$el.scrollTop(); if (this._index === 0 || this._index == this.data.length - 1 || itemTop < 0) { this.$el.scrollTop(itemTop + visibleTop); } else if (itemTop + itemHeight > visibleHeight) { this.$el.scrollTop(itemTop + itemHeight + visibleTop - visibleHeight); } }, _buildContents: function (zippedData) { var datum, i, index; var item = []; var html = ''; for (i = 0; i < zippedData.length; i++) { if (this.data.length === this.maxCount) break; datum = zippedData[i]; if (include(this.data, datum)) { continue; } index = this.data.length; this.data.push(datum); item.push(datum.strategy.template(datum.value)); } for (i = 0; i < item.length; i++) { html += '