From 6c7f55af95abd0254167f2adb65affe3d5fb9d49 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 19 Mar 2014 02:42:07 +0200 Subject: [PATCH 1/5] Add jQuery.textcomplete --- pootle/apps/pootle_app/assets.py | 2 + pootle/static/css/jquery.textcomplete.css | 33 ++ pootle/static/js/README | 10 + .../js/vendor/jquery/jquery.textcomplete.js | 550 ++++++++++++++++++ 4 files changed, 595 insertions(+) create mode 100644 pootle/static/css/jquery.textcomplete.css create mode 100644 pootle/static/js/vendor/jquery/jquery.textcomplete.js diff --git a/pootle/apps/pootle_app/assets.py b/pootle/apps/pootle_app/assets.py index 26e144d1ce7..bce00b71f47 100644 --- a/pootle/apps/pootle_app/assets.py +++ b/pootle/apps/pootle_app/assets.py @@ -57,6 +57,7 @@ js_editor = Bundle( 'js/vendor/jquery/jquery.history.js', 'js/vendor/jquery/jquery.textarea-expander.js', + 'js/vendor/jquery/jquery.textcomplete.js', 'js/vendor/diff_match_patch.js', 'js/vendor/jquery/jquery.caret.js', 'js/vendor/jquery/jquery.highlightRegex.js', @@ -95,5 +96,6 @@ css_editor = Bundle( 'css/editor.css', + 'css/jquery.textcomplete.css', filters='cssmin', output='css/editor.min.%(version)s.css') register('css_editor', css_editor) diff --git a/pootle/static/css/jquery.textcomplete.css b/pootle/static/css/jquery.textcomplete.css new file mode 100644 index 00000000000..37a761b7e40 --- /dev/null +++ b/pootle/static/css/jquery.textcomplete.css @@ -0,0 +1,33 @@ +/* Sample */ + +.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); +} + + +/* SHOULD not modify */ + +.dropdown-menu { + list-style: none; + padding: 0; + margin: 0; +} + +.dropdown-menu a:hover { + cursor: pointer; +} diff --git a/pootle/static/js/README b/pootle/static/js/README index 010e60446c7..208335d7e40 100644 --- a/pootle/static/js/README +++ b/pootle/static/js/README @@ -204,6 +204,16 @@ Directory contents http://blogs.sitepointstatic.com/examples/tech/textarea-expander/jquery.textarea-expander.js + - jquery.textcomplete.js + Autocomplete for Textarea. + + Using version 0.1.3. + + Introduces autocompleting power to textareas, like a GitHub comment + form has. + + https://github.com/yuku-t/jquery-textcomplete + - jquery.tipsy.js : Facebook-style tooltips for jQuery. diff --git a/pootle/static/js/vendor/jquery/jquery.textcomplete.js b/pootle/static/js/vendor/jquery/jquery.textcomplete.js new file mode 100644 index 00000000000..b5242ad5054 --- /dev/null +++ b/pootle/static/js/vendor/jquery/jquery.textcomplete.js @@ -0,0 +1,550 @@ +/*! + * 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) { + 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); From a8f3b999a8609de814ef07f9e2aa9b5603645c00 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Wed, 19 Mar 2014 06:29:59 +0200 Subject: [PATCH 2/5] Add support for auto-completing placeables --- pootle/static/css/editor.css | 4 ++++ pootle/static/js/editor.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/pootle/static/css/editor.css b/pootle/static/css/editor.css index 7e13fba18a7..a19066dd13a 100644 --- a/pootle/static/css/editor.css +++ b/pootle/static/css/editor.css @@ -660,6 +660,10 @@ td.translate-full textarea.translation min-height: 2.7em; } +.translate-translation .textcomplete-wrapper +{ + width: 100%; +} /* TM and suggestions */ diff --git a/pootle/static/js/editor.js b/pootle/static/js/editor.js index 1cc8d2f6153..916ff01d429 100644 --- a/pootle/static/js/editor.js +++ b/pootle/static/js/editor.js @@ -484,6 +484,8 @@ PTL.editor.hlSearch(); + PTL.editor.setupAutocomplete(); + if (PTL.editor.settings.tmUrl != '') { PTL.editor.getTMUnits(); } @@ -570,6 +572,35 @@ $(sel.join(", ")).highlightRegex(hlRegex); }, + setupAutocomplete: function () { + var searchFunc = function (term, callback) { + var $placeables = $("div.original .js-placeable"); + + var matching = $.map($placeables, function (placeable) { + var text = $(placeable).text(); + if (term.length !== 0 && text.indexOf(term) === 0) { + return text; + } else { + return null; + } + }); + + callback(matching); + }; + + var replaceFunc = function (value) { + return value; + }; + + $("textarea.translation").textcomplete([ + { + match: /(\S*)$/, + search: searchFunc, + replace: replaceFunc, + index: 1, + } + ]); + }, /* Copies text into the focused textarea */ copyText: function (e) { From 190f905ad0df931c2726212b1f802cf4cc1148dd Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Mon, 24 Mar 2014 09:29:16 +0200 Subject: [PATCH 3/5] Improve placeables inside right-to-left text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make them embedded left-to-right strings so that things like “%(foo)s” do not look like “foo)s)%” inside right-to-left strings (which can be seen when setting, say, Arabic as an alt source language). This does not affect translation field (which would benefit from this most), though. --- pootle/static/css/editor.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pootle/static/css/editor.css b/pootle/static/css/editor.css index a19066dd13a..e0789adcf58 100644 --- a/pootle/static/css/editor.css +++ b/pootle/static/css/editor.css @@ -1342,6 +1342,17 @@ a.editor-specialchar:hover color: #840; } +.highlight-escape, +.placeable +{ + /* Switch the next two lines to “unicode-bidi: isolate” once it is widely + * supported and let the browser determine the direction, instead of + * assuming that all placeables are left-to-right string. + */ + direction: ltr; + unicode-bidi: embed; +} + .placeable:hover, .highlight-escape:hover, #js-selected-placeable From 065d4984f558177b095edf9b9a433103caf3af6b Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sun, 30 Mar 2014 16:13:47 +0200 Subject: [PATCH 4/5] Fix the placement of the auto complete drop down For some reason we need to force block here otherwise the placement is off in Firefox. --- pootle/static/css/editor.css | 1 + 1 file changed, 1 insertion(+) diff --git a/pootle/static/css/editor.css b/pootle/static/css/editor.css index e0789adcf58..7453c793278 100644 --- a/pootle/static/css/editor.css +++ b/pootle/static/css/editor.css @@ -663,6 +663,7 @@ td.translate-full textarea.translation .translate-translation .textcomplete-wrapper { width: 100%; + display: block !important; } /* TM and suggestions */ From e3e58299375a7870250d0c6904baed2bccf309ed Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Sun, 30 Mar 2014 16:35:31 +0200 Subject: [PATCH 5/5] Escape HTML in autocomplete dropdown --- pootle/static/js/editor.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pootle/static/js/editor.js b/pootle/static/js/editor.js index 916ff01d429..952b60a2d57 100644 --- a/pootle/static/js/editor.js +++ b/pootle/static/js/editor.js @@ -577,9 +577,12 @@ var $placeables = $("div.original .js-placeable"); var matching = $.map($placeables, function (placeable) { - var text = $(placeable).text(); + var text = $(placeable).text(), + html = $(placeable).html(); if (term.length !== 0 && text.indexOf(term) === 0) { - return text; + // We return html not text here as the return value will used to + // populate the drop down menu unescaped. + return html; } else { return null; } @@ -589,7 +592,10 @@ }; var replaceFunc = function (value) { - return value; + // Unescape the HTML text we returned above for insertion into the + // textarea. + var text = $('
    ').html(value).text(); + return text; }; $("textarea.translation").textcomplete([