mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-25 08:12:40 +01:00
64b1b212df
Summary: Ref T10163. These are almost certainly not username/project characters, and are fairly likely to be `@{...}` Diviner references. Test Plan: Typed `@{...`, no more autocomplete. Reviewers: chad Reviewed By: chad Maniphest Tasks: T10163 Differential Revision: https://secure.phabricator.com/D15110
712 lines
19 KiB
JavaScript
712 lines
19 KiB
JavaScript
/**
|
|
* @provides phuix-autocomplete
|
|
* @requires javelin-install
|
|
* javelin-dom
|
|
* phuix-icon-view
|
|
* phabricator-prefab
|
|
*/
|
|
|
|
JX.install('PHUIXAutocomplete', {
|
|
|
|
construct: function() {
|
|
this._map = {};
|
|
this._datasources = {};
|
|
this._listNodes = [];
|
|
this._resultMap = {};
|
|
},
|
|
|
|
members: {
|
|
_area: null,
|
|
_active: false,
|
|
_cursorHead: null,
|
|
_cursorTail: null,
|
|
_pixelHead: null,
|
|
_pixelTail: null,
|
|
_map: null,
|
|
_datasource: null,
|
|
_datasources: null,
|
|
_value: null,
|
|
_node: null,
|
|
_echoNode: null,
|
|
_listNode: null,
|
|
_promptNode: null,
|
|
_focus: null,
|
|
_focusRef: null,
|
|
_listNodes: null,
|
|
_x: null,
|
|
_y: null,
|
|
_visible: false,
|
|
_resultMap: null,
|
|
|
|
setArea: function(area) {
|
|
this._area = area;
|
|
return this;
|
|
},
|
|
|
|
addAutocomplete: function(code, spec) {
|
|
this._map[code] = spec;
|
|
return this;
|
|
},
|
|
|
|
start: function() {
|
|
var area = this._area;
|
|
|
|
JX.DOM.listen(area, 'keypress', null, JX.bind(this, this._onkeypress));
|
|
|
|
JX.DOM.listen(
|
|
area,
|
|
['click', 'keyup', 'keydown', 'keypress'],
|
|
null,
|
|
JX.bind(this, this._update));
|
|
|
|
var select = JX.bind(this, this._onselect);
|
|
JX.DOM.listen(this._getNode(), 'mousedown', 'typeahead-result', select);
|
|
|
|
var device = JX.bind(this, this._ondevice);
|
|
JX.Stratcom.listen('phabricator-device-change', null, device);
|
|
|
|
// When the user clicks away from the textarea, deactivate.
|
|
var deactivate = JX.bind(this, this._deactivate);
|
|
JX.DOM.listen(area, 'blur', null, deactivate);
|
|
},
|
|
|
|
_getSpec: function() {
|
|
return this._map[this._active];
|
|
},
|
|
|
|
_ondevice: function() {
|
|
if (JX.Device.getDevice() != 'desktop') {
|
|
this._deactivate();
|
|
}
|
|
},
|
|
|
|
_activate: function(code) {
|
|
if (JX.Device.getDevice() != 'desktop') {
|
|
return;
|
|
}
|
|
|
|
if (!this._map[code]) {
|
|
return;
|
|
}
|
|
|
|
var area = this._area;
|
|
var range = JX.TextAreaUtils.getSelectionRange(area);
|
|
|
|
// Check the character immediately before the trigger character. We'll
|
|
// only activate the typeahead if it's something that we think a user
|
|
// might reasonably want to autocomplete after, like a space, newline,
|
|
// or open parenthesis. For example, if a user types "alincoln@",
|
|
// the prior letter will be the last "n" in "alincoln". They are probably
|
|
// typing an email address, not a username, so we don't activate the
|
|
// autocomplete.
|
|
var head = range.start;
|
|
var prior;
|
|
if (head > 1) {
|
|
prior = area.value.substring(head - 2, head - 1);
|
|
} else {
|
|
prior = '<start>';
|
|
}
|
|
|
|
switch (prior) {
|
|
case '<start>':
|
|
case ' ':
|
|
case '\n':
|
|
case '\t':
|
|
case '(': // Might be "(@username, what do you think?)".
|
|
case '-': // Might be an unnumbered list.
|
|
case '.': // Might be a numbered list.
|
|
case '|': // Might be a table cell.
|
|
case '>': // Might be a blockquote.
|
|
case '!': // Might be a blockquote attribution line.
|
|
case ':': // Might be a "NOTE:".
|
|
// We'll let these autocomplete.
|
|
break;
|
|
default:
|
|
// We bail out on anything else, since the user is probably not
|
|
// typing a username or project tag.
|
|
return;
|
|
}
|
|
|
|
// Get all the text on the current line. If the line only contains
|
|
// whitespace, don't actiavte: the user is probably typing code or a
|
|
// numbered list.
|
|
var line = area.value.substring(0, head - 1);
|
|
line = line.split('\n');
|
|
line = line[line.length - 1];
|
|
if (line.match(/^\s+$/)) {
|
|
return;
|
|
}
|
|
|
|
this._cursorHead = head;
|
|
this._cursorTail = range.end;
|
|
this._pixelHead = JX.TextAreaUtils.getPixelDimensions(
|
|
area,
|
|
range.start,
|
|
range.end);
|
|
|
|
var spec = this._map[code];
|
|
if (!this._datasources[code]) {
|
|
var datasource = new JX.TypeaheadOnDemandSource(spec.datasourceURI);
|
|
datasource.listen(
|
|
'resultsready',
|
|
JX.bind(this, this._onresults, code));
|
|
|
|
datasource.setTransformer(JX.bind(this, this._transformresult));
|
|
datasource.setSortHandler(
|
|
JX.bind(datasource, JX.Prefab.sortHandler, {}));
|
|
datasource.setFilterHandler(JX.Prefab.filterClosedResults);
|
|
|
|
this._datasources[code] = datasource;
|
|
}
|
|
|
|
this._datasource = this._datasources[code];
|
|
this._active = code;
|
|
|
|
var head_icon = new JX.PHUIXIconView()
|
|
.setIcon(spec.headerIcon)
|
|
.getNode();
|
|
var head_text = spec.headerText;
|
|
|
|
var node = this._getPromptNode();
|
|
JX.DOM.setContent(node, [head_icon, head_text]);
|
|
},
|
|
|
|
_transformresult: function(fields) {
|
|
var map = JX.Prefab.transformDatasourceResults(fields);
|
|
|
|
var icon;
|
|
if (map.icon) {
|
|
icon = new JX.PHUIXIconView()
|
|
.setIcon(map.icon)
|
|
.getNode();
|
|
}
|
|
|
|
map.display = [icon, map.displayName];
|
|
|
|
return map;
|
|
},
|
|
|
|
_deactivate: function() {
|
|
var node = this._getNode();
|
|
JX.DOM.hide(node);
|
|
|
|
this._active = false;
|
|
this._visible = false;
|
|
},
|
|
|
|
_onkeypress: function(e) {
|
|
var r = e.getRawEvent();
|
|
|
|
if (r.metaKey || r.altKey || r.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
var code = r.charCode;
|
|
if (this._map[code]) {
|
|
setTimeout(JX.bind(this, this._activate, code), 0);
|
|
}
|
|
},
|
|
|
|
_onresults: function(code, nodes, value, partial) {
|
|
// Even if these results are out of date, we still want to fill in the
|
|
// result map so we can terminate things later.
|
|
if (!partial) {
|
|
if (!this._resultMap[code]) {
|
|
this._resultMap[code] = {};
|
|
}
|
|
|
|
var hits = [];
|
|
for (var ii = 0; ii < nodes.length; ii++) {
|
|
var result = this._datasources[code].getResult(nodes[ii].rel);
|
|
if (!result) {
|
|
hits = null;
|
|
break;
|
|
}
|
|
|
|
if (!result.autocomplete || !result.autocomplete.length) {
|
|
hits = null;
|
|
break;
|
|
}
|
|
|
|
hits.push(result.autocomplete);
|
|
}
|
|
|
|
if (hits !== null) {
|
|
this._resultMap[code][value] = hits;
|
|
}
|
|
}
|
|
|
|
if (code !== this._active) {
|
|
return;
|
|
}
|
|
|
|
if (value !== this._value) {
|
|
return;
|
|
}
|
|
|
|
if (this._isTerminatedString(value)) {
|
|
if (this._hasUnrefinableResults(value)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
var list = this._getListNode();
|
|
JX.DOM.setContent(list, nodes);
|
|
|
|
this._listNodes = nodes;
|
|
|
|
var old_ref = this._focusRef;
|
|
this._clearFocus();
|
|
|
|
for (var ii = 0; ii < nodes.length; ii++) {
|
|
if (nodes[ii].rel == old_ref) {
|
|
this._setFocus(ii);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (this._focus === null && nodes.length) {
|
|
this._setFocus(0);
|
|
}
|
|
|
|
this._redraw();
|
|
},
|
|
|
|
_setFocus: function(idx) {
|
|
if (!this._listNodes[idx]) {
|
|
this._clearFocus();
|
|
return false;
|
|
}
|
|
|
|
if (this._focus !== null) {
|
|
JX.DOM.alterClass(this._listNodes[this._focus], 'focused', false);
|
|
}
|
|
|
|
this._focus = idx;
|
|
this._focusRef = this._listNodes[idx].rel;
|
|
JX.DOM.alterClass(this._listNodes[idx], 'focused', true);
|
|
|
|
return true;
|
|
},
|
|
|
|
_changeFocus: function(delta) {
|
|
if (this._focus === null) {
|
|
return false;
|
|
}
|
|
|
|
return this._setFocus(this._focus + delta);
|
|
},
|
|
|
|
_clearFocus: function() {
|
|
this._focus = null;
|
|
this._focusRef = null;
|
|
},
|
|
|
|
_onselect: function (e) {
|
|
if (!e.isNormalMouseEvent()) {
|
|
// Eat right clicks, control clicks, etc., on the results. These can
|
|
// not do anything meaningful and if we let them through they'll blur
|
|
// the field and dismiss the results.
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
var target = e.getNode('typeahead-result');
|
|
|
|
for (var ii = 0; ii < this._listNodes.length; ii++) {
|
|
if (this._listNodes[ii] === target) {
|
|
this._setFocus(ii);
|
|
this._autocomplete();
|
|
break;
|
|
}
|
|
}
|
|
|
|
this._deactivate();
|
|
e.kill();
|
|
},
|
|
|
|
_getSuffixes: function() {
|
|
return [' ', ':', ',', ')'];
|
|
},
|
|
|
|
_getCancelCharacters: function() {
|
|
// The "." character does not cancel because of projects named
|
|
// "node.js" or "blog.mycompany.com".
|
|
return ['#', '@', ',', '!', '?', '{', '}'];
|
|
},
|
|
|
|
_getTerminators: function() {
|
|
return [' ', ':', ',', '.', '!', '?'];
|
|
},
|
|
|
|
_isTerminatedString: function(string) {
|
|
var terminators = this._getTerminators();
|
|
for (var ii = 0; ii < terminators.length; ii++) {
|
|
var term = terminators[ii];
|
|
if (string.substring(string.length - term.length) == term) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_hasUnrefinableResults: function(query) {
|
|
if (!this._resultMap[this._active]) {
|
|
return false;
|
|
}
|
|
|
|
var map = this._resultMap[this._active];
|
|
|
|
for (var ii = 1; ii < query.length; ii++) {
|
|
var prefix = query.substring(0, ii);
|
|
if (map.hasOwnProperty(prefix)) {
|
|
var results = map[prefix];
|
|
|
|
// If any prefix of the query has no results, the full query also
|
|
// has no results so we can not refine them.
|
|
if (!results.length) {
|
|
return true;
|
|
}
|
|
|
|
// If there is exactly one match and the it is a prefix of the query,
|
|
// we can safely assume the user just typed out the right result
|
|
// from memory and doesn't need to refine it.
|
|
if (results.length == 1) {
|
|
// Strip the first character off, like a "#" or "@".
|
|
var result = results[0].substring(1);
|
|
|
|
if (query.length >= result.length) {
|
|
if (query.substring(0, result.length) === result) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_trim: function(str) {
|
|
var suffixes = this._getSuffixes();
|
|
for (var ii = 0; ii < suffixes.length; ii++) {
|
|
if (str.substring(str.length - suffixes[ii].length) == suffixes[ii]) {
|
|
str = str.substring(0, str.length - suffixes[ii].length);
|
|
}
|
|
}
|
|
return str;
|
|
},
|
|
|
|
_update: function(e) {
|
|
if (!this._active) {
|
|
return;
|
|
}
|
|
|
|
var special = e.getSpecialKey();
|
|
|
|
// Deactivate if the user types escape.
|
|
if (special == 'esc') {
|
|
this._deactivate();
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
var area = this._area;
|
|
|
|
if (e.getType() == 'keydown') {
|
|
if (special == 'up' || special == 'down') {
|
|
var delta = (special == 'up') ? -1 : +1;
|
|
if (!this._changeFocus(delta)) {
|
|
this._deactivate();
|
|
}
|
|
e.kill();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (special == 'tab' || special == 'return') {
|
|
var r = e.getRawEvent();
|
|
if (r.shiftKey && special == 'tab') {
|
|
// Don't treat "Shift + Tab" as an autocomplete action. Instead,
|
|
// let it through normally so the focus shifts to the previous
|
|
// control.
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
// If we autocomplete, we're done. Otherwise, just eat the event. This
|
|
// happens if you type too fast and try to tab complete before results
|
|
// load.
|
|
if (this._autocomplete()) {
|
|
this._deactivate();
|
|
}
|
|
|
|
e.kill();
|
|
return;
|
|
}
|
|
|
|
// Deactivate if the user moves the cursor to the left of the assist
|
|
// range. For example, they might press the "left" arrow to move the
|
|
// cursor to the left, or click in the textarea prior to the active
|
|
// range.
|
|
var range = JX.TextAreaUtils.getSelectionRange(area);
|
|
if (range.start < this._cursorHead) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
// Deactivate if the user moves the cursor to the right of the assist
|
|
// range. For example, they might click later in the document. If the user
|
|
// is pressing the "right" arrow key, they are not allowed to move the
|
|
// cursor beyond the existing end of the text range. If they are pressing
|
|
// other keys, assume they're typing and allow the tail to move forward
|
|
// one character.
|
|
var margin;
|
|
if (special == 'right') {
|
|
margin = 0;
|
|
} else {
|
|
margin = 1;
|
|
}
|
|
|
|
var tail = this._cursorTail;
|
|
|
|
if ((range.start > tail + margin) || (range.end > tail + margin)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
this._cursorTail = Math.max(this._cursorTail, range.end);
|
|
|
|
var text = area.value.substring(
|
|
this._cursorHead,
|
|
this._cursorTail);
|
|
|
|
this._value = text;
|
|
|
|
var pixels = JX.TextAreaUtils.getPixelDimensions(
|
|
area,
|
|
range.start,
|
|
range.end);
|
|
|
|
var x = this._pixelHead.start.x;
|
|
var y = Math.max(this._pixelHead.end.y, pixels.end.y) + 24;
|
|
|
|
// If the first character after the trigger is a space, just deactivate
|
|
// immediately. This occurs if a user types a numbered list using "#".
|
|
if (text.length && text[0] == ' ') {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
|
|
var trim = this._trim(text);
|
|
|
|
// Deactivate immediately if a user types a character that we are
|
|
// reasonably sure means they don't want to use the autocomplete. For
|
|
// example, "##" is almost certainly a header or monospaced text, not
|
|
// a project autocompletion.
|
|
var cancels = this._getCancelCharacters();
|
|
for (var ii = 0; ii < cancels.length; ii++) {
|
|
if (trim.indexOf(cancels[ii]) !== -1) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If the input is terminated by a space or another word-terminating
|
|
// punctuation mark, we're going to deactivate if the results can not
|
|
// be refined by addding more words.
|
|
|
|
// The idea is that if you type "@alan ab", you're allowed to keep
|
|
// editing "ab" until you type a space, period, or other terminator,
|
|
// since you might not be sure how to spell someone's last name or the
|
|
// second word of a project.
|
|
|
|
// Once you do terminate a word, if the words you have have entered match
|
|
// nothing or match only one exact match, we can safely deactivate and
|
|
// assume you're just typing text because further words could never
|
|
// refine the result set.
|
|
|
|
var force;
|
|
if (this._isTerminatedString(text)) {
|
|
if (this._hasUnrefinableResults(text)) {
|
|
this._deactivate();
|
|
return;
|
|
}
|
|
force = true;
|
|
} else {
|
|
force = false;
|
|
}
|
|
|
|
this._datasource.didChange(trim, force);
|
|
|
|
this._x = x;
|
|
this._y = y;
|
|
|
|
var hint = trim;
|
|
if (hint.length) {
|
|
// We only show the autocompleter after the user types at least one
|
|
// character. For example, "@" does not trigger it, but "@d" does.
|
|
this._visible = true;
|
|
} else {
|
|
hint = this._getSpec().hintText;
|
|
}
|
|
|
|
var echo = this._getEchoNode();
|
|
JX.DOM.setContent(echo, hint);
|
|
|
|
this._redraw();
|
|
},
|
|
|
|
_redraw: function() {
|
|
if (!this._visible) {
|
|
return;
|
|
}
|
|
|
|
var node = this._getNode();
|
|
JX.DOM.show(node);
|
|
|
|
var p = new JX.Vector(this._x, this._y);
|
|
var s = JX.Vector.getScroll();
|
|
var v = JX.Vector.getViewport();
|
|
|
|
// If the menu would run off the bottom of the screen when showing the
|
|
// maximum number of possible choices, put it above instead. We're doing
|
|
// this based on the maximum size so the menu doesn't jump up and down
|
|
// as results arrive.
|
|
|
|
var option_height = 30;
|
|
var extra_margin = 24;
|
|
if ((s.y + v.y) < (p.y + (5 * option_height) + extra_margin)) {
|
|
var d = JX.Vector.getDim(node);
|
|
p.y = p.y - d.y - 36;
|
|
}
|
|
|
|
p.setPos(node);
|
|
},
|
|
|
|
_autocomplete: function() {
|
|
if (this._focus === null) {
|
|
return false;
|
|
}
|
|
|
|
var area = this._area;
|
|
var head = this._cursorHead;
|
|
var tail = this._cursorTail;
|
|
|
|
var text = area.value;
|
|
|
|
var ref = this._focusRef;
|
|
var result = this._datasource.getResult(ref);
|
|
if (!result) {
|
|
return false;
|
|
}
|
|
|
|
ref = result.autocomplete;
|
|
if (!ref || !ref.length) {
|
|
return false;
|
|
}
|
|
|
|
// If the user types a string like "@username:" (with a trailing colon),
|
|
// then presses tab or return to pick the completion, don't destroy the
|
|
// trailing character.
|
|
var suffixes = this._getSuffixes();
|
|
var value = this._value;
|
|
var found_suffix = false;
|
|
for (var ii = 0; ii < suffixes.length; ii++) {
|
|
var last = value.substring(value.length - suffixes[ii].length);
|
|
if (last == suffixes[ii]) {
|
|
ref += suffixes[ii];
|
|
found_suffix = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If we didn't find an existing suffix, add a space.
|
|
if (!found_suffix) {
|
|
ref = ref + ' ';
|
|
}
|
|
|
|
area.value = text.substring(0, head - 1) + ref + text.substring(tail);
|
|
|
|
var end = head + ref.length;
|
|
JX.TextAreaUtils.setSelectionRange(area, end, end);
|
|
|
|
return true;
|
|
},
|
|
|
|
_getNode: function() {
|
|
if (!this._node) {
|
|
var head = this._getHeadNode();
|
|
var list = this._getListNode();
|
|
|
|
this._node = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete',
|
|
style: {
|
|
display: 'none'
|
|
}
|
|
},
|
|
[head, list]);
|
|
|
|
JX.DOM.hide(this._node);
|
|
|
|
document.body.appendChild(this._node);
|
|
}
|
|
return this._node;
|
|
},
|
|
|
|
_getHeadNode: function() {
|
|
if (!this._headNode) {
|
|
this._headNode = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete-head'
|
|
},
|
|
[
|
|
this._getPromptNode(),
|
|
this._getEchoNode()
|
|
]);
|
|
}
|
|
|
|
return this._headNode;
|
|
},
|
|
|
|
_getPromptNode: function() {
|
|
if (!this._promptNode) {
|
|
this._promptNode = JX.$N(
|
|
'span',
|
|
{
|
|
className: 'phuix-autocomplete-prompt',
|
|
});
|
|
}
|
|
return this._promptNode;
|
|
},
|
|
|
|
_getEchoNode: function() {
|
|
if (!this._echoNode) {
|
|
this._echoNode = JX.$N(
|
|
'span',
|
|
{
|
|
className: 'phuix-autocomplete-echo'
|
|
});
|
|
}
|
|
return this._echoNode;
|
|
},
|
|
|
|
_getListNode: function() {
|
|
if (!this._listNode) {
|
|
this._listNode = JX.$N(
|
|
'div',
|
|
{
|
|
className: 'phuix-autocomplete-list'
|
|
});
|
|
}
|
|
return this._listNode;
|
|
}
|
|
|
|
}
|
|
|
|
});
|