1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-18 19:40:55 +01:00
phorge-phorge/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
epriestley dba1b10720 Deactivate the remarkup autosuggest once text can't match "[[" or "((" rules
Summary:
See PHI1185, which reports a performance issue with "(" in remarkup in certain contexts.

I can't reproduce the performance issue, but I can reproduce the autosuggester incorrectly remaining active and swallowing return characters.

When the user types `(` or `[`, we wait for a prefix for the `((` (Phurl) or `[[` (Phriction) rules. We currently continue looking for that prefix until a character is entered that explicitly interrupts the search.

For example, typing `(xxx<return>` does not insert a return character, because we're stuck on matching the prefix.

Instead, as soon as the user has entered text that we know won't ever match the prefix, deactivate the autocomplete. We can slightly cheat through this by just looking for at least one character of text, since all prefixes are exactly one character long. If we eventually have some kind of `~~@(xyz)` rule we might need to add a more complicated piece of rejection logic.

Test Plan: Typed `(xxx<return>`, got a return. Used `((` and `[[` autosuggest rules normally. Used `JX.log()` to sanity check that nothing too crazy seems to be happening.

Reviewers: amckinley

Reviewed By: amckinley

Differential Revision: https://secure.phabricator.com/D20365
2019-04-01 15:40:38 -07:00

782 lines
21 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>';
}
// If this is a repeating sequence and the previous character is the
// same as the one the user just typed, like "((", don't reactivate.
if (prior === String.fromCharCode(code)) {
return;
}
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.
// 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 activate: 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, {}));
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();
}
var dot;
if (map.availabilityColor) {
dot = JX.$N(
'span',
{
className: 'phui-tag-dot phui-tag-color-' + map.availabilityColor
});
}
var display = JX.$N('span', {}, [icon, dot, map.displayName]);
JX.DOM.alterClass(display, 'tokenizer-result-closed', !!map.closed);
map.display = display;
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();
// NOTE: We allow events to continue with "altKey", because you need
// to press Alt to type characters like "@" on a German keyboard layout.
// The cost of misfiring autocompleters is very small since we do not
// eat the keystroke. See T10252.
if (r.metaKey || (r.ctrlKey && !r.altKey)) {
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".
var defaults = ['#', '@', ',', '!', '?', '{', '}'];
return this._map[this._active].cancel || defaults;
},
_getTerminators: function() {
return [' ', ':', ',', '.', '!', '?'];
},
_getIgnoreList: function() {
return this._map[this._active].ignore || [];
},
_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;
}
}
// 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;
}
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 the user hasn't typed any text yet after typing the character
// which can summon the autocomplete, deactivate and let the keystroke
// through. For example, we hit this when a line ends with an
// autocomplete character and the user is trying to type a newline.
if (range.start == this._cursorHead) {
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 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);
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;
}
// 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 (text.indexOf(cancels[ii]) !== -1) {
this._deactivate();
return;
}
}
var trim = this._trim(text);
// If this rule has a prefix pattern, like the "[[ document ]]" rule,
// require it match and throw it away before we begin suggesting
// results. The autocomplete remains active, it's just dormant until
// the user gives us more to work with.
var prefix = this._map[this._active].prefix;
if (prefix) {
var pattern = new RegExp(prefix);
if (!trim.match(pattern)) {
// If the prefix pattern can not match the text, deactivate. (This
// check might need to be more careful if we have a more varied
// set of prefixes in the future, but for now they're all a single
// prefix character.)
if (trim.length) {
this._deactivate();
}
return;
}
trim = trim.replace(pattern, '');
trim = trim.trim();
}
// Store the current value now that we've finished mutating the text.
// This needs to match what we pass to the typeahead datasource.
this._value = trim;
// Deactivate immediately if the user types an ignored token like ":)",
// the smiley face emoticon. Note that we test against "text", not
// "trim", because the ignore list and suffix list can otherwise
// interact destructively.
var ignore = this._getIgnoreList();
for (ii = 0; ii < ignore.length; ii++) {
if (text.indexOf(ignore[ii]) === 0) {
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 adding 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;
}
}
});