/** * @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 = ''; } switch (prior) { case '': 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; } } });