From 0c792e3e11fdb3969de044970a04aceeb9061c20 Mon Sep 17 00:00:00 2001 From: Thomas Leishman Date: Wed, 3 Feb 2016 11:57:52 -0700 Subject: [PATCH] Updating jquery overlay and textcomplete libs --- assets/javascripts/jquery.overlay.js | 35 +- assets/javascripts/jquery.textcomplete.js | 1587 +++++++++++++++------ assets/stylesheets/auto_complete.css | 73 +- 3 files changed, 1175 insertions(+), 520 deletions(-) diff --git a/assets/javascripts/jquery.overlay.js b/assets/javascripts/jquery.overlay.js index 8575a5a..83ce8e7 100644 --- a/assets/javascripts/jquery.overlay.js +++ b/assets/javascripts/jquery.overlay.js @@ -47,9 +47,11 @@ * Function for escaping strings to HTML interpolation. */ var escape = function (str) { - return str.replace(entityRegexe, function (match) { - return entityMap[match]; - }) + if (typeof str !== 'undefined'){ + return str.replace(entityRegexe, function (match) { + return entityMap[match]; + }) + } }; /** @@ -108,7 +110,6 @@ 'font-family', 'font-weight', 'font-size', - 'width', 'background-color' ]; @@ -174,7 +175,7 @@ renderTextOnOverlay: function () { var text, i, l, strategy, match, style; - text = escape(this.$textarea.val()); + text = $('
').text(this.$textarea.val()); // Apply all strategies for (i = 0, l = this.strategies.length; i < l; i++) { @@ -190,11 +191,25 @@ // Style attribute's string style = 'background-color:' + strategy.css['background-color']; - text = text.replace(match, function (str) { - return '' + str + ''; + text.contents().each(function () { + var text, html, str, prevIndex; + if (this.nodeType != Node.TEXT_NODE) return; + text = this.textContent; + html = ''; + for (prevIndex = match.lastIndex = 0;; prevIndex = match.lastIndex) { + str = match.exec(text); + if (!str) { + if (prevIndex) html += escape(text.substr(prevIndex)); + break; + } + str = str[0]; + html += escape(text.substr(prevIndex, match.lastIndex - prevIndex - str.length)); + html += '' + escape(str) + ''; + }; + if (prevIndex) $(this).replaceWith(html); }); } - this.$el.html(text); + this.$el.html(text.contents()); return this; }, @@ -213,7 +228,7 @@ this.$textarea.off('.overlay'); $wrapper = this.$textarea.parent(); $wrapper.after(this.$textarea).remove(); - this.$textarea.data('overlay', void 0); + this.$textarea.removeData('overlay'); this.$textarea = null; } }); @@ -245,4 +260,4 @@ }); }; -})(window.jQuery); +})(window.jQuery); \ No newline at end of file diff --git a/assets/javascripts/jquery.textcomplete.js b/assets/javascripts/jquery.textcomplete.js index 795cda5..08549e4 100644 --- a/assets/javascripts/jquery.textcomplete.js +++ b/assets/javascripts/jquery.textcomplete.js @@ -1,463 +1,494 @@ -/*! - * jQuery.textcomplete.js - * - * Repositiory: https://github.com/yuku-t/jquery-textcomplete - * License: MIT - * Author: Yuku Takahashi - */ +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery'], factory); + } else if (typeof module === "object" && module.exports) { + var $ = require('jquery'); + module.exports = factory($); + } else { + // Browser globals + factory(jQuery); + } +}(function (jQuery) { -;(function ($) { + /*! + * 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 + */ - 'use strict'; + if (typeof jQuery === 'undefined') { + throw new Error('jQuery.textcomplete requires jQuery'); + } - /** - * 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); + +function ($) { + 'use strict'; + + var warn = function (message) { + if (console.warn) { console.warn(message); } + }; + + var id = 1; + + $.fn.textcomplete = function (strategies, option) { + var args = Array.prototype.slice.call(arguments); + return this.each(function () { + var self = this; + var $this = $(this); + var completer = $this.data('textComplete'); + if (!completer) { + option || (option = {}); + option._oid = id++; // unique object id + 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 { - locked = false; + // 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, { + el: self, + $el: $this + })); } }); - 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); + }(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; + } }); - return styles; + func.apply(this, args); }; - } - })(); + }; - /** - * Default template function. - */ - var identity = function (obj) { return obj; }; + var isString = function (obj) { + return Object.prototype.toString.call(obj) === '[object String]'; + }; - /** - * 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); - }); - } + var isFunction = function (obj) { + return Object.prototype.toString.call(obj) === '[object Function]'; }; - }; - /** - * 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; - }; + var uniqueId = 0; - /** - * Textarea manager class. - */ - var Completer = (function () { - var html, css, $baseWrapper, $baseList, _id; + function Completer(element, option) { + this.$el = $(element); + this.id = 'textcomplete' + uniqueId++; + this.strategies = []; + this.views = []; + this.option = $.extend({}, Completer._getDefaults(), option); - html = { - wrapper: '
', - list: '' - }; - css = { - wrapper: { - lineHeight: '1.25em', - clear: 'left', - position: 'relative' - }, - // Removed the 'top' property to support the placement: 'top' option - list: { - position: 'absolute', - left: 0, - zIndex: '100', - display: 'none' + 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.'); } - }; - $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(); + + if (element === document.activeElement) { + // element has already been focused. Initialize view objects immediately. + this.initialize() } else { - this.$el.one('focus.textComplete', $.proxy(this.initialize, this)); + // Initialize view objects lazily. + var self = this; + this.$el.one('focus.' + this.id, function () { self.initialize(); }); } } - /** - * Completer's public methods - */ + Completer._getDefaults = function () { + if (!Completer.DEFAULTS) { + Completer.DEFAULTS = { + appendTo: $('body'), + zIndex: '100' + }; + } + + return Completer.DEFAULTS; + } + $.extend(Completer.prototype, { + // Public properties + // ----------------- - /** - * 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); - }, + id: null, + option: null, + strategies: null, + adapter: null, + dropdown: null, + $el: null, - /** - * 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); - } + // Public methods + // -------------- - if (!this.listView.data.length && this.listView.shown) { - this.listView.deactivate(); + initialize: function () { + var element = this.$el.get(0); + // Initialize view objects. + this.dropdown = new $.fn.textcomplete.Dropdown(element, this, this.option); + 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); }, - 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; - } - }; + 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; }, - /** - * Keyup event handler. - */ - onKeyup: function (e) { - var searchQuery, term; - if (this.skipSearch(e)) { return; } - - searchQuery = this.extractSearchQuery(this.getTextFromHeadToCaret()); + // 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) { - term = searchQuery[1]; - if (this.term === term) return; // Ignore shift-key or something. - this.term = term; - this.search(searchQuery); + var term = searchQuery[1]; + // Ignore shift-key, ctrl-key and so on. + if (skipUnchangedTerm && this._term === term) { return; } + this._term = term; + this._search.apply(this, searchQuery); } else { - this.term = null; - this.listView.deactivate(); + this._term = null; + this.dropdown.deactivate(); } }, - /** - * Suppress searching if it returns true. - */ - skipSearch: function (e) { - 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; - } + fire: function (eventName) { + var args = Array.prototype.slice.call(arguments, 1); + this.$el.trigger(eventName, args); + return this; }, - 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; + register: function (strategies) { + Array.prototype.push.apply(this.strategies, strategies); }, - /** - * Global click event handler. - */ - onClickDocument: function (e) { - if (e.originalEvent && !e.originalEvent.keepTextCompleteDropdown) { - this.listView.deactivate(); - } + // 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. + // e - Click or keydown event object. + select: function (value, strategy, e) { + this._term = null; + this.adapter.select(value, strategy, e); + this.fire('change').fire('textComplete:select', value, strategy); + this.adapter.focus(); }, - /** - * Global keyup event handler. - */ - onKeyupDocument: function (e) { - if (this.listView.shown && e.keyCode === 27) { // ESC - this.listView.deactivate(); - this.$el.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 === '') { + var matchRegexp = isFunction(strategy.match) ? strategy.match(text) : strategy.match; + if (isString(context)) { text = context; } + var match = text.match(matchRegexp); + if (match) { return [strategy, match[strategy.index], match]; } + } } + return [] }, - /** - * 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. - - 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' - ]; - scrollbar = this.$el[0].scrollHeight > this.$el[0].offsetHeight; - css = $.extend({ - position: 'absolute', - overflow: scrollbar ? 'scroll' : '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; - }, + // 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(); + } + if (self._clearAtNext) { + // The first callback in the current lock. + self.dropdown.clear(); + self._clearAtNext = false; + } + self.dropdown.setPosition(self.adapter.getCaretPosition()); + self.dropdown.render(self._zip(data, strategy, term)); + if (!stillSearching) { + // The last callback in the current lock. + free(); + self._clearAtNext = true; // Call dropdown.clear at the next time. + } + }, match); + }), - 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) { - var i, l, strategy, match; - for (i = 0, l = this.strategies.length; i < l; i++) { - strategy = this.strategies[i]; - match = text.toLowerCase().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)); - }) + // Build a parameter for Dropdown#render. + // + // Examples + // + // this._zip(['a', 'b'], 's'); + // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }] + _zip: function (data, strategy, term) { + return $.map(data, function (value) { + return { value: value, strategy: strategy, term: term }; + }); + } }); - /** - * Completer's private functions - */ - var wrapElement = function ($el) { - return $el.wrap($baseWrapper.clone().css('display', $el.css('display'))); + $.fn.textcomplete.Completer = Completer; + }(jQuery); + + +function ($) { + 'use strict'; + + var $window = $(window); + + 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; }; - return Completer; - })(); + var dropdownViews = {}; + $(document).on('click', function (e) { + var id = e.originalEvent && e.originalEvent.keepTextCompleteDropdown; + $.each(dropdownViews, function (key, view) { + if (key !== id) { view.deactivate(); } + }); + }); - /** - * Dropdown menu manager class. - */ - var ListView = (function () { + var commands = { + SKIP_DEFAULT: 0, + KEY_UP: 1, + KEY_DOWN: 2, + KEY_ENTER: 3, + KEY_PAGEUP: 4, + KEY_PAGEDOWN: 5, + KEY_ESCAPE: 6 + }; - function ListView($el, completer) { - this.data = []; - this.$el = $el; - this.index = 0; + // Dropdown view + // ============= + + // Construct Dropdown object. + // + // element - Textarea or contenteditable element. + function Dropdown(element, completer, option) { + this.$el = Dropdown.createElement(option); this.completer = completer; + this.id = completer.id + 'dropdown'; + this._data = []; // zipped data. + this.$inputEl = $(element); + this.option = option; - this.$el.on('click.textComplete', 'li.textcomplete-item', - $.proxy(this.onClick, this)); + // 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', 'noResultsMessage', 'className'], function (_i, name) { + if (option[name] != null) { self[name] = option[name]; } + }); + this._bindEvents(element); + dropdownViews[this.id] = this; } - $.extend(ListView.prototype, { - shown: false, + $.extend(Dropdown, { + // Class methods + // ------------- - render: function (data) { - var html, i, l, index, val; + createElement: function (option) { + var $parent = option.appendTo; + if (!($parent instanceof $)) { $parent = $($parent); } + var $el = $('') + .addClass('dropdown-menu textcomplete-dropdown') + .attr('id', 'textcomplete-dropdown-' + option._oid) + .css({ + display: 'none', + left: 0, + position: 'absolute', + zIndex: option.zIndex + }) + .appendTo($parent); + return $el; + } + }); - 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) { + $.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._fitToBottom(); + this._activateIndexedItem(); + } + this._setScroll(); + } else if (this.noResultsMessage) { + this._renderNoResultsMessage(unzippedData); + } else if (this.shown) { this.deactivate(); - } else { - this.activateIndexedItem(); } }, - clear: function () { - this.data = []; - this.$el.html(''); - this.index = 0; - return this; - }, + setPosition: function (pos) { + this.$el.css(this._applyPlacement(pos)); + + // 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 - activateIndexedItem: function () { - this.$el.find('.active').removeClass('active'); - this.getActiveItem().addClass('active'); + return this; }, - getActiveItem: function () { - return $(this.$el.children().get(this.index)); + clear: function () { + this.$el.html(''); + this.data = []; + this._index = 0; + this._$header = this._$footer = this._$noResultsMessage = null; }, activate: function () { if (!this.shown) { + this.clear(); this.$el.show(); - this.completer.$el.trigger('textComplete:show'); + if (this.className) { this.$el.addClass(this.className); } + this.completer.fire('textComplete:show'); this.shown = true; } return this; @@ -466,123 +497,731 @@ deactivate: function () { if (this.shown) { this.$el.hide(); - this.completer.$el.trigger('textComplete:hide'); + if (this.className) { this.$el.removeClass(this.className); } + this.completer.fire('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')); + 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, + _$noResultsMessage: null, + _$footer: null, + + // Private methods + // --------------- + + _bindEvents: function () { + this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this)); + this.$el.on('touchstart.' + 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.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, e); + 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(); + if (e.type === 'touchstart') { + self.$inputEl.focus(); + } + }, 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; } + + var command; + + if ($.isFunction(this.option.onKeydown)) { + command = this.option.onKeydown(e, commands); + } + + if (command == null) { + command = this._defaultKeydown(e); + } + + switch (command) { + case commands.KEY_UP: + e.preventDefault(); + this._up(); + break; + case commands.KEY_DOWN: + e.preventDefault(); + this._down(); + break; + case commands.KEY_ENTER: + e.preventDefault(); + this._enter(e); + break; + case commands.KEY_PAGEUP: + e.preventDefault(); + this._pageup(); + break; + case commands.KEY_PAGEDOWN: + e.preventDefault(); + this._pagedown(); + break; + case commands.KEY_ESCAPE: + e.preventDefault(); + this.deactivate(); + break; + } + }, + + _defaultKeydown: function (e) { + if (this.isUp(e)) { + return commands.KEY_UP; + } else if (this.isDown(e)) { + return commands.KEY_DOWN; + } else if (this.isEnter(e)) { + return commands.KEY_ENTER; + } else if (this.isPageup(e)) { + return commands.KEY_PAGEUP; + } else if (this.isPagedown(e)) { + return commands.KEY_PAGEDOWN; + } else if (this.isEscape(e)) { + return commands.KEY_ESCAPE; + } + }, + + _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 (e) { + var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)]; + this.completer.select(datum.value, datum.strategy, e); + 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:nth(' + 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 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); + html += '
  • '; + html += datum.strategy.template(datum.value, datum.term); + html += '
  • '; + } + return html; + }, + + _renderHeader: function (unzippedData) { + if (this.header) { + if (!this._$header) { + this._$header = $('
  • ').prependTo(this.$el); + } + var html = $.isFunction(this.header) ? this.header(unzippedData) : this.header; + this._$header.html(html); + } + }, + + _renderFooter: function (unzippedData) { + if (this.footer) { + if (!this._$footer) { + this._$footer = $('').appendTo(this.$el); + } + var html = $.isFunction(this.footer) ? this.footer(unzippedData) : this.footer; + this._$footer.html(html); + } + }, + + _renderNoResultsMessage: function (unzippedData) { + if (this.noResultsMessage) { + if (!this._$noResultsMessage) { + this._$noResultsMessage = $('
  • ').appendTo(this.$el); + } + var html = $.isFunction(this.noResultsMessage) ? this.noResultsMessage(unzippedData) : this.noResultsMessage; + this._$noResultsMessage.html(html); + } + }, + + _renderContents: function (html) { + if (this._$footer) { + this._$footer.before(html); + } else { + this.$el.append(html); + } + }, + + _fitToBottom: function() { + var windowScrollBottom = $window.scrollTop() + $window.height(); + var height = this.$el.height(); + if ((this.$el.position().top + height) > windowScrollBottom) { + this.$el.offset({top: windowScrollBottom - height}); + } + }, + + _applyPlacement: function (position) { + // If the 'placement' option set to 'top', move the position above the element. + if (this.placement.indexOf('top') !== -1) { // Overwrite the position object to set the 'bottom' property instead of the top. position = { top: 'auto', - bottom: this.$el.parent().height() - position.top + fontSize, + bottom: this.$el.parent().height() - position.top + position.lineHeight, left: position.left }; } else { - // Overwrite 'bottom' property because once `placement: 'top'` - // strategy is shown, $el keeps the property. position.bottom = 'auto'; + delete position.lineHeight; } - this.$el.css(position); - return this; + if (this.placement.indexOf('absleft') !== -1) { + position.left = 0; + } else if (this.placement.indexOf('absright') !== -1) { + position.right = 0; + position.left = 'auto'; + } + return position; + } + }); + + $.fn.textcomplete.Dropdown = Dropdown; + $.extend($.fn.textcomplete, commands); + }(jQuery); + + +function ($) { + 'use strict'; + + // 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); + }); + } + }; + }; + + function Strategy(options) { + $.extend(this, options); + if (this.cache) { this.search = memoize(this.search); } + } + + Strategy.parse = function (strategiesArray, params) { + return $.map(strategiesArray, function (strategy) { + var strategyObj = new Strategy(strategy); + strategyObj.el = params.el; + strategyObj.$el = params.$el; + return strategyObj; + }); + }; + + $.extend(Strategy.prototype, { + // Public properties + // ----------------- + + // Required + match: null, + replace: null, + search: null, + + // Optional + cache: false, + context: function () { return true; }, + index: 2, + template: function (obj) { return obj; }, + idProperty: null + }); + + $.fn.textcomplete.Strategy = Strategy; + + }(jQuery); + + +function ($) { + 'use strict'; + + var now = Date.now || function () { return new Date().getTime(); }; + + // Returns a function, that, as long as it continues to be invoked, will not + // be triggered. The function will be called after it stops being called for + // `wait` msec. + // + // This utility function was originally implemented at Underscore.js. + var debounce = function (func, wait) { + var timeout, args, context, timestamp, result; + var later = function () { + var last = now() - timestamp; + if (last < wait) { + timeout = setTimeout(later, wait - last); + } else { + timeout = null; + result = func.apply(context, args); + context = args = null; + } + }; + + return function () { + context = this; + args = arguments; + timestamp = now(); + if (!timeout) { + timeout = setTimeout(later, wait); + } + return result; + }; + }; + + function Adapter () {} + + $.extend(Adapter.prototype, { + // Public properties + // ----------------- + + id: null, // Identity. + completer: null, // Completer object which creates it. + el: null, // Textarea element. + $el: null, // jQuery object of the textarea. + option: null, + + // Public methods + // -------------- + + initialize: function (element, completer, option) { + this.el = element; + this.$el = $(element); + this.id = completer.id + this.constructor.name; + this.completer = completer; + this.option = option; + + if (this.option.debounce) { + this._onKeyup = debounce(this._onKeyup, this.option.debounce); + } + + this._bindEvents(); }, - 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); + destroy: function () { + this.$el.off('.' + this.id); // Remove all event handlers. + this.$el = this.el = this.completer = null; }, - 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; + // Update the element with the given value and strategy. + // + // value - The selected object. It is one of the item of the array + // which was callbacked from the search function. + // strategy - The Strategy associated with the selected value. + select: function (/* value, strategy */) { + throw new Error('Not implemented'); + }, + + // Returns the caret's relative coordinates from body's left top corner. + // + // FIXME: Calculate the left top corner of `this.option.appendTo` element. + getCaretPosition: function () { + var position = this._getCaretRelativePosition(); + var offset = this.$el.offset(); + position.top += offset.top; + position.left += offset.left; + return position; + }, + + // Focus on the element. + focus: function () { + this.$el.focus(); + }, + + // Private methods + // --------------- + + _bindEvents: function () { + this.$el.on('keyup.' + this.id, $.proxy(this._onKeyup, this)); + }, + + _onKeyup: function (e) { + if (this._skipSearch(e)) { return; } + this.completer.trigger(this.getTextFromHeadToCaret(), true); + }, + + // Suppress searching if it returns true. + _skipSearch: function (clickEvent) { + switch (clickEvent.keyCode) { + case 13: // ENTER + case 40: // DOWN + case 38: // UP + return true; + } + if (clickEvent.ctrlKey) switch (clickEvent.keyCode) { + case 78: // Ctrl-N + case 80: // Ctrl-P + return true; + } + } + }); + + $.fn.textcomplete.Adapter = Adapter; + }(jQuery); + + +function ($) { + 'use strict'; + + // Textarea adapter + // ================ + // + // Managing a textarea. It doesn't know a Dropdown. + function Textarea(element, completer, option) { + this.initialize(element, completer, option); + } + + Textarea.DIV_PROPERTIES = { + left: -9999, + position: 'absolute', + top: 0, + whiteSpace: 'pre-wrap' + } + + Textarea.COPY_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' + ]; + + $.extend(Textarea.prototype, $.fn.textcomplete.Adapter.prototype, { + // Public methods + // -------------- + + // Update the textarea with the given value and strategy. + select: function (value, strategy, e) { + var pre = this.getTextFromHeadToCaret(); + var post = this.el.value.substring(this.el.selectionEnd); + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; } - this.activateIndexedItem(); - } else if (e.keyCode === 13 || e.keyCode === 9) { // ENTER or TAB - e.preventDefault(); - this.select(parseInt(this.getActiveItem().data('index'), 10)); + pre = pre.replace(strategy.match, newSubstr); + this.$el.val(pre + post); + this.el.selectionStart = this.el.selectionEnd = pre.length; } }, - 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)); + // Private methods + // --------------- + + // Returns the caret's relative coordinates from textarea's left top corner. + // + // 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 dummy 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. + _getCaretRelativePosition: function () { + var dummyDiv = $('
    ').css(this._copyCss()) + .text(this.getTextFromHeadToCaret()); + var span = $('').text('.').appendTo(dummyDiv); + this.$el.before(dummyDiv); + var position = span.position(); + position.top += span.height() - this.$el.scrollTop(); + position.lineHeight = span.height(); + dummyDiv.remove(); + return position; }, - destroy: function () { - this.deactivate(); - this.$el.off('click.textComplete').remove(); - this.$el = null; + _copyCss: function () { + return $.extend({ + // Set 'scroll' if a scrollbar is being shown; otherwise 'auto'. + overflow: this.el.scrollHeight > this.el.offsetHeight ? 'scroll' : 'auto' + }, Textarea.DIV_PROPERTIES, this._getStyles()); + }, + + _getStyles: (function ($) { + var color = $('
    ').css(['color']).color; + if (typeof color !== 'undefined') { + return function () { + return this.$el.css(Textarea.COPY_PROPERTIES); + }; + } else { // jQuery < 1.8 + return function () { + var $el = this.$el; + var styles = {}; + $.each(Textarea.COPY_PROPERTIES, function (i, property) { + styles[property] = $el.css(property); + }); + return styles; + }; + } + })($), + + getTextFromHeadToCaret: function () { + return this.el.value.substring(0, this.el.selectionEnd); } }); - return ListView; - })(); + $.fn.textcomplete.Textarea = Textarea; + }(jQuery); - $.fn.textcomplete = function (strategies) { - var i, l, strategy, dataKey; + +function ($) { + 'use strict'; - dataKey = 'textComplete'; + var sentinelChar = '吶'; - if (strategies === 'destroy') { - return this.each(function () { - var completer = $(this).data(dataKey); - if (completer) { completer.destroy(); } - }); + function IETextarea(element, completer, option) { + this.initialize(element, completer, option); + $('' + sentinelChar + '').css({ + position: 'absolute', + top: -9999, + left: -9999 + }).insertBefore(element); } - 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); + $.extend(IETextarea.prototype, $.fn.textcomplete.Textarea.prototype, { + // Public methods + // -------------- + + select: function (value, strategy, e) { + var pre = this.getTextFromHeadToCaret(); + var post = this.el.value.substring(pre.length); + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; + } + pre = pre.replace(strategy.match, newSubstr); + this.$el.val(pre + post); + this.el.focus(); + var range = this.el.createTextRange(); + range.collapse(true); + range.moveEnd('character', pre.length); + range.moveStart('character', pre.length); + range.select(); + } + }, + + getTextFromHeadToCaret: function () { + this.el.focus(); + var range = document.selection.createRange(); + range.moveStart('character', -this.el.value.length); + var arr = range.text.split(sentinelChar) + return arr.length === 1 ? arr[0] : arr[1]; } - strategy.maxCount || (strategy.maxCount = 10); + }); + + $.fn.textcomplete.IETextarea = IETextarea; + }(jQuery); + +// NOTE: TextComplete plugin has contenteditable support but it does not work +// fine especially on old IEs. +// Any pull requests are REALLY welcome. + + +function ($) { + 'use strict'; + + // ContentEditable adapter + // ======================= + // + // Adapter for contenteditable elements. + function ContentEditable (element, completer, option) { + this.initialize(element, completer, option); } - return this.each(function () { - var $this, completer; - $this = $(this); - completer = $this.data(dataKey); - if (!completer) { - completer = new Completer($this); - $this.data(dataKey, completer); + $.extend(ContentEditable.prototype, $.fn.textcomplete.Adapter.prototype, { + // Public methods + // -------------- + + // Update the content with the given value and strategy. + // When an dropdown item is selected, it is executed. + select: function (value, strategy, e) { + var pre = this.getTextFromHeadToCaret(); + var sel = window.getSelection() + var range = sel.getRangeAt(0); + var selection = range.cloneRange(); + selection.selectNodeContents(range.startContainer); + var content = selection.toString(); + var post = content.substring(range.startOffset); + var newSubstr = strategy.replace(value, e); + if (typeof newSubstr !== 'undefined') { + if ($.isArray(newSubstr)) { + post = newSubstr[1] + post; + newSubstr = newSubstr[0]; + } + pre = pre.replace(strategy.match, newSubstr); + range.selectNodeContents(range.startContainer); + range.deleteContents(); + var node = document.createTextNode(pre + post); + range.insertNode(node); + range.setStart(node, pre.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + }, + + // Private methods + // --------------- + + // Returns the caret's relative position from the contenteditable's + // left top corner. + // + // Examples + // + // this._getCaretRelativePosition() + // //=> { top: 18, left: 200, lineHeight: 16 } + // + // Dropdown's position will be decided using the result. + _getCaretRelativePosition: function () { + var range = window.getSelection().getRangeAt(0).cloneRange(); + var node = document.createElement('span'); + range.insertNode(node); + range.selectNodeContents(node); + range.deleteContents(); + var $node = $(node); + var position = $node.offset(); + position.left -= this.$el.offset().left; + position.top += $node.height() - this.$el.offset().top; + position.lineHeight = $node.height(); + $node.remove(); + return position; + }, + + // Returns the string between the first character and the caret. + // Completer will be triggered with the result for start autocompleting. + // + // Example + // + // // Suppose the html is 'hello wor|ld' and | is the caret. + // this.getTextFromHeadToCaret() + // // => ' wor' // not 'hello wor' + getTextFromHeadToCaret: function () { + var range = window.getSelection().getRangeAt(0); + var selection = range.cloneRange(); + selection.selectNodeContents(range.startContainer); + return selection.toString().substring(0, range.startOffset); } - completer.register(strategies); }); - }; -})(window.jQuery || window.Zepto); + $.fn.textcomplete.ContentEditable = ContentEditable; + }(jQuery); + + return jQuery; +})); \ No newline at end of file diff --git a/assets/stylesheets/auto_complete.css b/assets/stylesheets/auto_complete.css index 7836d93..7988d65 100644 --- a/assets/stylesheets/auto_complete.css +++ b/assets/stylesheets/auto_complete.css @@ -1,43 +1,44 @@ -.dropdown-menu{ - top: 6px !important; - background-color: #fff; - padding-left: 0px; - border: 1px solid #DDDDDD; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); - border-radius: 3px 3px 3px 3px; - min-width: 100px; -} -.textcomplete-item{ - margin-left: 0; - list-style: none; - border-bottom: 1px solid #eee; - padding-bottom: 5px; - padding-top: 4px; - padding-left: 10px; -} -.textcomplete-item a{ - color: #169; -} -.active{ - color: #169; -} -.dropdown-menu li.active{ - background-color: #169; - border-radius: 3px 3px 3px 3px; -} -.dropdown-menu li.active a{ +.dropdown-menu { + border: 1px solid #ddd; + background-color: white; +} + +.dropdown-menu li { + border-top: 1px solid #ddd; + padding: 2px 5px; +} + +.dropdown-menu li:first-child { + border-top: none; +} + +.dropdown-menu li:hover, +.dropdown-menu .active { + background-color: rgb(110, 183, 219); +} + +.dropdown-menu li:hover a, +.dropdown-menu .active a{ color: white; } -.dropdown-menu li:hover a{ - background-color: #169; - border-radius: 3px 3px 3px 3px; - color: #fff; + +.textoverlay { + line-height: normal; +} + + +/* SHOULD not modify */ + +.dropdown-menu { + list-style: none; + padding: 0; + margin: 0; } -.dropdown-menu li:hover{ - background-color: #169; - border-radius: 3px 3px 3px 3px; - color: #fff; + +.dropdown-menu a:hover { + cursor: pointer; } + .wiki-edit{ display:block; width: 100%;