This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.
osr-discourse-src/app/assets/javascripts/discourse/lib/autocomplete.js.es6
cpradio 4b71fd253b Advanced Search UI
Properly support Categories so it updates the search box correctly

Use category id, as it is more consistent with search results than using the slugs, especially for parent/subcategory

Added Status

Improve AutoComplete so it can receive updates
Added the ability for AutoComplete to receive updates to badge-selector and group-selector

Respect null, which is set via web-hooks

Support both # and category: for category detection.

Only update the searchedTerms if they differ from its current value (this helps the Category Selector receive updates)

Opt in receive updates (#3)

* Make the selectors opt-in for receiving updates

* Opt-in to receive updates

* Fix category detection for search-advanced-options

Fix eslint error

Update user-selector so it can receive updates live too
Make the canReceiveUpdates check validate against 'true'

Converted to use template literals

Refactor the regex involved with this feature
Split apart the init to make it a bit more manageable/testable

Switch the category selector to category-chooser, so it is a dropdown of categories instead of auto-complete

Reduce RegEx to make this happier with unicode languages and reduce some of the complexity
2016-10-04 11:18:01 -04:00

508 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' || options.updateData) {
Ember.run.cancel(inputTimeout);
$(this).off('keyup.autocomplete')
.off('keydown.autocomplete')
.off('paste.autocomplete')
.off('click.autocomplete');
if (options === 'destroy')
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();
if (options.updateData) {
wrap = this.parent();
wrap.find('.item').remove();
me.show();
} else {
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', (options.updateData) ? 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);
}
if(options.single){
me.hide();
}
addInputSelectedItem(x);
}
});
if(options.items) {
_.each(options.items, function(item){
if(options.single){
me.hide();
}
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;
}