This repository has been archived on 2025-05-04. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
discourse/app/assets/javascripts/discourse/lib/autocomplete.js.es6

488 lines
12 KiB
JavaScript

/**
This is a jQuery plugin to support autocompleting values in our text fields.
@module $.fn.autocomplete
**/
export const CANCELLED_STATUS = "__CANCELLED";
import { setCaretPosition, caretPosition } from 'discourse/lib/utilities';
const allowedLettersRegex = /[\s\t\[\{\(\/]/;
const keys = {
backSpace: 8,
tab: 9,
enter: 13,
shift: 16,
ctrl: 17,
alt: 18,
esc: 27,
space: 32,
leftWindows: 91,
rightWindows: 92,
pageUp: 33,
pageDown: 34,
end: 35,
home: 36,
leftArrow: 37,
upArrow: 38,
rightArrow: 39,
downArrow: 40,
insert: 45,
deleteKey: 46,
zero: 48,
a: 65,
z: 90,
};
let inputTimeout;
export default function(options) {
const autocompletePlugin = this;
if (this.length === 0) return;
if (options === 'destroy') {
Ember.run.cancel(inputTimeout);
$(this).off('keyup.autocomplete')
.off('keydown.autocomplete')
.off('paste.autocomplete')
.off('click.autocomplete');
return;
}
if (options && options.cancel && this.data("closeAutocomplete")) {
this.data("closeAutocomplete")();
return this;
}
if (this.length !== 1) {
if (window.console) {
window.console.log("WARNING: passed multiple elements to $.autocomplete, skipping.");
if (window.Error) {
window.console.log((new window.Error()).stack);
}
}
return this;
}
const disabled = options && options.disabled;
let wrap = null;
let autocompleteOptions = null;
let selectedOption = null;
let completeStart = null;
let completeEnd = null;
let me = this;
let div = null;
let prevTerm = null;
// input is handled differently
const isInput = this[0].tagName === "INPUT";
let inputSelectedItems = [];
function closeAutocomplete() {
if (div) {
div.hide().remove();
}
div = null;
completeStart = null;
autocompleteOptions = null;
prevTerm = null;
}
function addInputSelectedItem(item) {
var transformed,
transformedItem = item;
if (options.transformComplete) { transformedItem = options.transformComplete(transformedItem); }
// dump what we have in single mode, just in case
if (options.single) { inputSelectedItems = []; }
transformed = _.isArray(transformedItem) ? transformedItem : [transformedItem || item];
const divs = transformed.map(itm => {
let d = $(`<div class='item'><span>${itm}<a class='remove' href><i class='fa fa-times'></i></a></span></div>`);
const $parent = me.parent();
const prev = $parent.find('.item:last');
if (prev.length === 0) {
me.parent().prepend(d);
} else {
prev.after(d);
}
inputSelectedItems.push(itm);
return d[0];
});
if (options.onChangeItems) { options.onChangeItems(inputSelectedItems); }
$(divs).find('a').click(function() {
closeAutocomplete();
inputSelectedItems.splice($.inArray(transformedItem, inputSelectedItems), 1);
$(this).parent().parent().remove();
if (options.single) {
me.show();
}
if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems);
}
return false;
});
};
var completeTerm = function(term) {
if (term) {
if (isInput) {
me.val("");
if(options.single){
me.hide();
}
addInputSelectedItem(term);
} else {
if (options.transformComplete) {
term = options.transformComplete(term);
}
if (term) {
var text = me.val();
text = text.substring(0, completeStart) + (options.key || "") + term + ' ' + text.substring(completeEnd + 1, text.length);
me.val(text);
setCaretPosition(me[0], completeStart + 1 + term.length);
if (options && options.afterComplete) {
options.afterComplete(text);
}
}
}
}
closeAutocomplete();
};
if (isInput) {
const width = this.width();
wrap = this.wrap("<div class='ac-wrap clearfix" + (disabled ? " disabled": "") + "'/>").parent();
wrap.width(width);
if(options.single) {
this.css("width","100%");
} else {
this.width(150);
}
this.attr('name', this.attr('name') + "-renamed");
var vals = this.val().split(",");
_.each(vals,function(x) {
if (x !== "") {
if (options.reverseTransform) {
x = options.reverseTransform(x);
}
addInputSelectedItem(x);
}
});
if(options.items) {
_.each(options.items, function(item){
addInputSelectedItem(item);
});
}
this.val("");
completeStart = 0;
wrap.click(function() {
autocompletePlugin.focus();
return true;
});
}
function markSelected() {
const links = div.find('li a');
links.removeClass('selected');
return $(links[selectedOption]).addClass('selected');
};
function renderAutocomplete() {
if (div) {
div.hide().remove();
}
if (autocompleteOptions.length === 0) return;
div = $(options.template({ options: autocompleteOptions }));
var ul = div.find('ul');
selectedOption = 0;
markSelected();
ul.find('li').click(function() {
selectedOption = ul.find('li').index(this);
completeTerm(autocompleteOptions[selectedOption]);
return false;
});
var pos = null;
var vOffset = 0;
var hOffset = 0;
if (isInput) {
pos = {
left: 0,
top: 0
};
vOffset = -32;
hOffset = 0;
} else {
pos = me.caretPosition({
pos: completeStart,
key: options.key
});
hOffset = 27;
}
div.css({
left: "-1000px"
});
me.parent().append(div);
if (!isInput) {
vOffset = div.height();
if ((window.innerHeight - me.outerHeight() - $("header.d-header").innerHeight()) < vOffset) {
vOffset = -23;
}
if (Discourse.Site.currentProp('mobileView')) {
div.css('width', 'auto');
if ((me.height() / 2) >= pos.top) { vOffset = -23; }
if ((me.width() / 2) <= pos.left) { hOffset = -div.width(); }
}
}
var mePos = me.position();
var borderTop = parseInt(me.css('border-top-width'), 10) || 0;
div.css({
position: 'absolute',
top: (mePos.top + pos.top - vOffset + borderTop) + 'px',
left: (mePos.left + pos.left + hOffset) + 'px'
});
};
const SKIP = "skip";
function dataSource(term, opts) {
if (prevTerm === term) {
return SKIP;
}
prevTerm = term;
if (term.length !== 0 && term.trim().length === 0) {
closeAutocomplete();
return null;
} else {
return opts.dataSource(term);
}
};
function updateAutoComplete(r) {
if (completeStart === null || r === SKIP) return;
if (r && r.then && typeof(r.then) === "function") {
if (div) {
div.hide().remove();
}
r.then(updateAutoComplete);
return;
}
// Allow an update method to cancel. This allows us to debounce
// promises without leaking
if (r === CANCELLED_STATUS) {
return;
}
autocompleteOptions = r;
if (!r || r.length === 0) {
closeAutocomplete();
} else {
renderAutocomplete();
}
};
// chain to allow multiples
const oldClose = me.data("closeAutocomplete");
me.data("closeAutocomplete", function() {
if (oldClose) {
oldClose();
}
closeAutocomplete();
});
$(this).on('click.autocomplete', () => closeAutocomplete());
$(this).on('paste.autocomplete', function() {
_.delay(function(){
me.trigger("keydown");
}, 50);
});
function checkTriggerRule(opts) {
return options.triggerRule ? options.triggerRule(me[0], opts) : true;
};
$(this).on('keyup.autocomplete', function(e) {
if ([keys.esc, keys.enter].indexOf(e.which) !== -1) return true;
var cp = caretPosition(me[0]);
if (options.key && completeStart === null && cp > 0) {
var key = me[0].value[cp-1];
if (key === options.key) {
var prevChar = me.val().charAt(cp-2);
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = cp-1;
updateAutoComplete(dataSource("", options));
}
}
} else if (completeStart !== null) {
var term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
updateAutoComplete(dataSource(term, options));
}
});
$(this).on('keydown.autocomplete', function(e) {
var c, i, initial, prev, prevIsGood, stopFound, term, total, userToComplete;
let cp;
if (e.ctrlKey || e.altKey || e.metaKey){
return true;
}
if (options.allowAny){
// saves us wiring up a change event as well
Ember.run.cancel(inputTimeout);
inputTimeout = Ember.run.later(function(){
if(inputSelectedItems.length === 0) {
inputSelectedItems.push("");
}
if(_.isString(inputSelectedItems[0]) && me.val().length > 0) {
inputSelectedItems.pop();
inputSelectedItems.push(me.val());
if (options.onChangeItems) {
options.onChangeItems(inputSelectedItems);
}
}
}, 50);
}
if (!options.key) {
completeStart = 0;
}
if (e.which === keys.shift) return;
if ((completeStart === null) && e.which === keys.backSpace && options.key) {
c = caretPosition(me[0]);
c -= 1;
initial = c;
prevIsGood = true;
while (prevIsGood && c >= 0) {
c -= 1;
prev = me[0].value[c];
stopFound = prev === options.key;
if (stopFound) {
prev = me[0].value[c - 1];
if (checkTriggerRule({ backSpace: true }) && (!prev || allowedLettersRegex.test(prev))) {
completeStart = c;
cp = completeEnd = initial;
term = me[0].value.substring(c + 1, initial);
updateAutoComplete(dataSource(term, options));
return true;
}
}
prevIsGood = /[a-zA-Z\.-]/.test(prev);
}
}
// ESC
if (e.which === keys.esc) {
if (div !== null) {
closeAutocomplete();
return false;
}
return true;
}
if (completeStart !== null) {
cp = caretPosition(me[0]);
// allow people to right arrow out of completion
if (e.which === keys.rightArrow && me[0].value[cp] === ' ') {
closeAutocomplete();
return true;
}
// If we've backspaced past the beginning, cancel unless no key
if (cp <= completeStart && options.key) {
closeAutocomplete();
return true;
}
// Keyboard codes! So 80's.
switch (e.which) {
case keys.enter:
case keys.tab:
if (!autocompleteOptions) return true;
if (selectedOption >= 0 && (userToComplete = autocompleteOptions[selectedOption])) {
completeTerm(userToComplete);
} else {
// We're cancelling it, really.
return true;
}
e.stopImmediatePropagation();
return false;
case keys.upArrow:
selectedOption = selectedOption - 1;
if (selectedOption < 0) {
selectedOption = 0;
}
markSelected();
return false;
case keys.downArrow:
total = autocompleteOptions.length;
selectedOption = selectedOption + 1;
if (selectedOption >= total) {
selectedOption = total - 1;
}
if (selectedOption < 0) {
selectedOption = 0;
}
markSelected();
return false;
case keys.backSpace:
completeEnd = cp;
cp--;
if (cp < 0) {
closeAutocomplete();
if (isInput) {
i = wrap.find('a:last');
if (i) {
i.click();
}
}
return true;
}
term = me.val().substring(completeStart + (options.key ? 1 : 0), cp);
if ((completeStart === cp) && (term === options.key)) {
closeAutocomplete();
}
updateAutoComplete(dataSource(term, options));
return true;
default:
completeEnd = cp;
return true;
}
}
});
return this;
}