Commit 8e49b7db authored by Ryan Herbert's avatar Ryan Herbert

Replace jquery.autocomplete.js with jquery.atwho.js

Although the jquery plugin autocomplete.js was providing the basic
functionnality we needed, it was not natively able to display the list
of suggestions in the desired position for our textareas.

At.js is capable through the use of Caret.js of displaying a suggestion
box under the caret in a textarea.

This javascript code is heavily inspired by the code present in the
gitlab-ce project and allows us to have a nicely cached data-set to
limit database queries.
parent 016b984a
...@@ -2119,3 +2119,78 @@ label.highlight_label { ...@@ -2119,3 +2119,78 @@ label.highlight_label {
.widestBox { .widestBox {
min-width: @width_axisBox; min-width: @width_axisBox;
} }
/* atwho css */
.atwho-view {
position:absolute;
top: 0;
left: 0;
display: none;
margin-top: 18px;
background: white;
color: black;
border: 1px solid #DDD;
border-radius: 3px;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
min-width: 120px;
z-index: 11110 !important;
}
.atwho-view .atwho-header {
padding: 5px;
margin: 5px;
cursor: pointer;
border-bottom: solid 1px #eaeff1;
color: #6f8092;
font-size: 11px;
font-weight: bold;
}
.atwho-view .atwho-header .small {
color: #6f8092;
float: right;
padding-top: 2px;
margin-right: -5px;
font-size: 12px;
font-weight: normal;
}
.atwho-view .atwho-header:hover {
cursor: default;
}
.atwho-view .cur {
background: #3366FF;
color: white;
}
.atwho-view .cur small {
color: white;
}
.atwho-view strong {
color: #3366FF;
}
.atwho-view .cur strong {
color: white;
font:bold;
}
.atwho-view ul {
/* width: 100px; */
list-style:none;
padding:0;
margin:auto;
max-height: 200px;
overflow-y: auto;
}
.atwho-view ul li {
display: block;
padding: 5px 10px;
border-bottom: 1px solid #DDD;
cursor: pointer;
/* border-top: 1px solid #C8C8C8; */
}
.atwho-view small {
font-size: smaller;
color: #777;
font-weight: normal;
}
...@@ -13,7 +13,8 @@ require(["jquery", ...@@ -13,7 +13,8 @@ require(["jquery",
"file", "file",
"tsne", "tsne",
"jstree.min", "jstree.min",
"jquery.autocomplete"], function() { "jquery.caret",
"jquery.atwho"], function() {
// Then config file (needed by Vidjil) // Then config file (needed by Vidjil)
require(['../conf'], function() { require(['../conf'], function() {
loadAfterConf() loadAfterConf()
......
...@@ -20,13 +20,147 @@ ...@@ -20,13 +20,147 @@
* along with "Vidjil". If not, see <http://www.gnu.org/licenses/> * along with "Vidjil". If not, see <http://www.gnu.org/licenses/>
*/ */
// This code is heavily inspired by the code produced for Gitlab's GfmAutoComplete
function init_autocomplete(elem, group_id) { function VidjilAutoComplete(datasource) {
var service = 'tag/auto_complete'; if (typeof VidjilAutoComplete.instance === 'object') {
var address = db.db_address + service; return VidjilAutoComplete.instance;
$(elem).autocomplete({'serviceUrl': address, }
'dataType': 'json',
'params': {'group_id': group_id}, this.datasource = datasource;
'delimiter': /[,\.-_=+()$%^&*!@\[\]\{\}\"|'?\\\/><\s]/ this.isLoadingData = {};
this.cachedData = {};
this.loadedGroup = -1;
VidjilAutoComplete.instance = this;
return this;
}
// static methods
VidjilAutoComplete.defaultLoadingData = ['loading'];
VidjilAutoComplete.isLoading = function(data) {
dataToInspect = data;
if (data && data.length > 0) {
dataToInspect = data[0];
}
var loadingState = VidjilAutoComplete.defaultLoadingData[0];
return dataToInspect && (dataToInspect === loadingState || dataToInspect.name === loadingState);
}
// instance methods
VidjilAutoComplete.prototype = {
initCache : function(at) {
this.cachedData[at] = {};
},
clearCache : function() {
this.cachedData = {};
},
isLoaded : function(group_id) {
return this.loadedGroup == group_id;
},
setupAtWho: function(input) {
const $input = $(input);
if ($input.data('needs-atwho')) {
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupTags.bind(this, $input));
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
// Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
$input.on('clear-commands-cache.atwho', () => this.clearCache());
$input.data('needs-atwho', false);
// hackiness to get the autocompletion ready right away
$input.trigger('focus.setupAtWho');
}
},
setupTags : function($input) {
console.log("setup");
var self = this;
var at = '#';
this.initCache(at);
$input.atwho({
at: at,
alias: 'tags',
data: VidjilAutoComplete.defaultLoadingData,
callbacks: {
...self.getDefaultCallbacks()
},
searchKey: 'search',
}); });
},
getDefaultCallbacks : function() {
const fetchData = this.fetchData.bind(this);
const isLoaded = this.isLoaded.bind(this);
var callbacks = {
filter : function(query, data, searchKey) {
var group_id = this.$inputor.data('group-id');
if (VidjilAutoComplete.isLoading(data) || !isLoaded(group_id)) {
this.$inputor.atwho('load', this.at, VidjilAutoComplete.defaultLoadingData);
fetchData(this.$inputor, this.at, group_id);
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
},
beforeSave(tags) {
return $.map(tags, (i) => {
if (i.name == null) {
return i;
}
return {
id: i.id,
name: i.name,
search: `${i.id} ${i.name}`,
};
});
},
}
return callbacks;
},
fetchData : function($input, at, group_id) {
var self = this;
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
if (this.cachedData[at][group_id]) {
this.loadData($input, at, this.cachedData[at][group_id], group_id);
} else {
$.ajax({
type: "GET",
data: {
group_id: group_id
},
timeout: 5000,
crossDomain: true,
url: self.datasource,
success: function (data) {
var my_data = JSON.parse(data);
self.loadData($input, at, my_data, group_id);
},
error: function (request, status, error) {
self.isLoadingData[at] = false;
}
});
}
},
loadData : function($input, at, data, group_id) {
this.isLoadingData[at] = false;
this.cachedData[at][group_id] = data;
this.loadedGroup = group_id;
$input.atwho('load', at, data);
// This trigger at.js again
// otherwise we would be stuck with loading until the user types
return $input.trigger('keyup');
},
} }
/**
* at.js - 1.5.1
* Copyright (c) 2016 chord.luo <chord.luo@gmail.com>;
* Homepage: http://ichord.github.com/At.js
* License: MIT
*/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module unless amdModuleId is set
define(["jquery"], function (a0) {
return (factory(a0));
});
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require("jquery"));
} else {
factory(jQuery);
}
}(this, function ($) {
var DEFAULT_CALLBACKS, KEY_CODE;
KEY_CODE = {
DOWN: 40,
UP: 38,
ESC: 27,
TAB: 9,
ENTER: 13,
CTRL: 17,
A: 65,
P: 80,
N: 78,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
BACKSPACE: 8,
SPACE: 32
};
DEFAULT_CALLBACKS = {
beforeSave: function(data) {
return Controller.arrayToDefaultHash(data);
},
matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
var _a, _y, match, regexp, space;
flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
if (should_startWithSpace) {
flag = '(?:^|\\s)' + flag;
}
_a = decodeURI("%C3%80");
_y = decodeURI("%C3%BF");
space = acceptSpaceBar ? "\ " : "";
regexp = new RegExp(flag + "([A-Za-z" + _a + "-" + _y + "0-9_" + space + "\'\.\+\-]*)$|" + flag + "([^\\x00-\\xff]*)$", 'gi');
match = regexp.exec(subtext);
if (match) {
return match[2] || match[1];
} else {
return null;
}
},
filter: function(query, data, searchKey) {
var _results, i, item, len;
_results = [];
for (i = 0, len = data.length; i < len; i++) {
item = data[i];
if (~new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase())) {
_results.push(item);
}
}
return _results;
},
remoteFilter: null,
sorter: function(query, items, searchKey) {
var _results, i, item, len;
if (!query) {
return items;
}
_results = [];
for (i = 0, len = items.length; i < len; i++) {
item = items[i];
item.atwho_order = new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase());
if (item.atwho_order > -1) {
_results.push(item);
}
}
return _results.sort(function(a, b) {
return a.atwho_order - b.atwho_order;
});
},
tplEval: function(tpl, map) {
var error, error1, template;
template = tpl;
try {
if (typeof tpl !== 'string') {
template = tpl(map);
}
return template.replace(/\$\{([^\}]*)\}/g, function(tag, key, pos) {
return map[key];
});
} catch (error1) {
error = error1;
return "";
}
},
highlighter: function(li, query) {
var regexp;
if (!query) {
return li;
}
regexp = new RegExp(">\\s*(\\w*?)(" + query.replace("+", "\\+") + ")(\\w*)\\s*<", 'ig');
return li.replace(regexp, function(str, $1, $2, $3) {
return '> ' + $1 + '<strong>' + $2 + '</strong>' + $3 + ' <';
});
},
beforeInsert: function(value, $li, e) {
return value;
},
beforeReposition: function(offset) {
return offset;
},
afterMatchFailed: function(at, el) {}
};
var App;
App = (function() {
function App(inputor) {
this.currentFlag = null;
this.controllers = {};
this.aliasMaps = {};
this.$inputor = $(inputor);
this.setupRootElement();
this.listen();
}
App.prototype.createContainer = function(doc) {
var ref;
if ((ref = this.$el) != null) {
ref.remove();
}
return $(doc.body).append(this.$el = $("<div class='atwho-container'></div>"));
};
App.prototype.setupRootElement = function(iframe, asRoot) {
var error, error1;
if (asRoot == null) {
asRoot = false;
}
if (iframe) {
this.window = iframe.contentWindow;
this.document = iframe.contentDocument || this.window.document;
this.iframe = iframe;
} else {
this.document = this.$inputor[0].ownerDocument;
this.window = this.document.defaultView || this.document.parentWindow;
try {
this.iframe = this.window.frameElement;
} catch (error1) {
error = error1;
this.iframe = null;
if ($.fn.atwho.debug) {
throw new Error("iframe auto-discovery is failed.\nPlease use `setIframe` to set the target iframe manually.\n" + error);
}
}
}
return this.createContainer((this.iframeAsRoot = asRoot) ? this.document : document);
};
App.prototype.controller = function(at) {
var c, current, currentFlag, ref;
if (this.aliasMaps[at]) {
current = this.controllers[this.aliasMaps[at]];
} else {
ref = this.controllers;
for (currentFlag in ref) {
c = ref[currentFlag];
if (currentFlag === at) {
current = c;
break;
}
}
}
if (current) {
return current;
} else {
return this.controllers[this.currentFlag];
}
};
App.prototype.setContextFor = function(at) {
this.currentFlag = at;
return this;
};
App.prototype.reg = function(flag, setting) {
var base, controller;
controller = (base = this.controllers)[flag] || (base[flag] = this.$inputor.is('[contentEditable]') ? new EditableController(this, flag) : new TextareaController(this, flag));
if (setting.alias) {
this.aliasMaps[setting.alias] = flag;
}
controller.init(setting);
return this;
};
App.prototype.listen = function() {
return this.$inputor.on('compositionstart', (function(_this) {
return function(e) {
var ref;
if ((ref = _this.controller()) != null) {
ref.view.hide();
}
_this.isComposing = true;
return null;
};
})(this)).on('compositionend', (function(_this) {
return function(e) {
_this.isComposing = false;
setTimeout(function(e) {
return _this.dispatch(e);
});
return null;
};
})(this)).on('keyup.atwhoInner', (function(_this) {
return function(e) {
return _this.onKeyup(e);
};
})(this)).on('keydown.atwhoInner', (function(_this) {
return function(e) {
return _this.onKeydown(e);
};
})(this)).on('blur.atwhoInner', (function(_this) {
return function(e) {
var c;
if (c = _this.controller()) {
c.expectedQueryCBId = null;
return c.view.hide(e, c.getOpt("displayTimeout"));
}
};
})(this)).on('click.atwhoInner', (function(_this) {
return function(e) {
return _this.dispatch(e);
};
})(this)).on('scroll.atwhoInner', (function(_this) {
return function() {
var lastScrollTop;
lastScrollTop = _this.$inputor.scrollTop();
return function(e) {
var currentScrollTop, ref;
currentScrollTop = e.target.scrollTop;
if (lastScrollTop !== currentScrollTop) {
if ((ref = _this.controller()) != null) {
ref.view.hide(e);
}
}
lastScrollTop = currentScrollTop;
return true;
};
};
})(this)());
};
App.prototype.shutdown = function() {
var _, c, ref;
ref = this.controllers;
for (_ in ref) {
c = ref[_];
c.destroy();
delete this.controllers[_];
}
this.$inputor.off('.atwhoInner');
return this.$el.remove();
};
App.prototype.dispatch = function(e) {
var _, c, ref, results;
ref = this.controllers;
results = [];
for (_ in ref) {
c = ref[_];
results.push(c.lookUp(e));
}
return results;
};
App.prototype.onKeyup = function(e) {
var ref;
switch (e.keyCode) {
case KEY_CODE.ESC:
e.preventDefault();
if ((ref = this.controller()) != null) {
ref.view.hide();
}
break;
case KEY_CODE.DOWN:
case KEY_CODE.UP:
case KEY_CODE.CTRL:
case KEY_CODE.ENTER:
$.noop();
break;
case KEY_CODE.P:
case KEY_CODE.N:
if (!e.ctrlKey) {
this.dispatch(e);
}
break;
default:
this.dispatch(e);
}
};
App.prototype.onKeydown = function(e) {
var ref, view;
view = (ref = this.controller()) != null ? ref.view : void 0;
if (!(view && view.visible())) {
return;
}
switch (e.keyCode) {
case KEY_CODE.ESC:
e.preventDefault();
view.hide(e);
break;
case KEY_CODE.UP:
e.preventDefault();
view.prev();
break;
case KEY_CODE.DOWN:
e.preventDefault();
view.next();
break;
case KEY_CODE.P:
if (!e.ctrlKey) {
return;
}
e.preventDefault();
view.prev();
break;
case KEY_CODE.N:
if (!e.ctrlKey) {
return;
}
e.preventDefault();
view.next();
break;
case KEY_CODE.TAB:
case KEY_CODE.ENTER:
case KEY_CODE.SPACE:
if (!view.visible()) {
return;
}