1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-30 02:32:42 +01:00
phorge-phorge/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
epriestley 7eaa27683e Make closed/disabled results in the remarkup autocomplete more visually clear
Summary:
Ref T13114. See PHI522. Although it looks like results are already ordered correctly, the override rendering isn't accommodating disabled results gracefully.

Give closed results a distinctive look (grey + strikethru) so it's clear when you're autocompleting `@mention...` into a disabled user.

Test Plan: {F5497621}

Maniphest Tasks: T13114

Differential Revision: https://secure.phabricator.com/D19272
2018-03-30 08:47:00 -07:00

766 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 display = JX.$N('span', {}, [icon, 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)) {
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;
}
}
});