Path: blob/master/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
12241 views
/**1* @provides phuix-autocomplete2* @requires javelin-install3* javelin-dom4* phuix-icon-view5* phabricator-prefab6*/78JX.install('PHUIXAutocomplete', {910construct: function() {11this._map = {};12this._datasources = {};13this._listNodes = [];14this._resultMap = {};15},1617members: {18_area: null,19_active: false,20_cursorHead: null,21_cursorTail: null,22_pixelHead: null,23_pixelTail: null,24_map: null,25_datasource: null,26_datasources: null,27_value: null,28_node: null,29_echoNode: null,30_listNode: null,31_promptNode: null,32_focus: null,33_focusRef: null,34_listNodes: null,35_x: null,36_y: null,37_visible: false,38_resultMap: null,3940setArea: function(area) {41this._area = area;42return this;43},4445addAutocomplete: function(code, spec) {46this._map[code] = spec;47return this;48},4950start: function() {51var area = this._area;5253JX.DOM.listen(area, 'keypress', null, JX.bind(this, this._onkeypress));5455JX.DOM.listen(56area,57['click', 'keyup', 'keydown', 'keypress'],58null,59JX.bind(this, this._update));6061var select = JX.bind(this, this._onselect);62JX.DOM.listen(this._getNode(), 'mousedown', 'typeahead-result', select);6364var device = JX.bind(this, this._ondevice);65JX.Stratcom.listen('phabricator-device-change', null, device);6667// When the user clicks away from the textarea, deactivate.68var deactivate = JX.bind(this, this._deactivate);69JX.DOM.listen(area, 'blur', null, deactivate);70},7172_getSpec: function() {73return this._map[this._active];74},7576_ondevice: function() {77if (JX.Device.getDevice() != 'desktop') {78this._deactivate();79}80},8182_activate: function(code) {83if (JX.Device.getDevice() != 'desktop') {84return;85}8687if (!this._map[code]) {88return;89}9091var area = this._area;92var range = JX.TextAreaUtils.getSelectionRange(area);9394// Check the character immediately before the trigger character. We'll95// only activate the typeahead if it's something that we think a user96// might reasonably want to autocomplete after, like a space, newline,97// or open parenthesis. For example, if a user types "alincoln@",98// the prior letter will be the last "n" in "alincoln". They are probably99// typing an email address, not a username, so we don't activate the100// autocomplete.101var head = range.start;102var prior;103if (head > 1) {104prior = area.value.substring(head - 2, head - 1);105} else {106prior = '<start>';107}108109// If this is a repeating sequence and the previous character is the110// same as the one the user just typed, like "((", don't reactivate.111if (prior === String.fromCharCode(code)) {112return;113}114115switch (prior) {116case '<start>':117case ' ':118case '\n':119case '\t':120case '(': // Might be "(@username, what do you think?)".121case '-': // Might be an unnumbered list.122case '.': // Might be a numbered list.123case '|': // Might be a table cell.124case '>': // Might be a blockquote.125case '!': // Might be a blockquote attribution line.126// We'll let these autocomplete.127break;128default:129// We bail out on anything else, since the user is probably not130// typing a username or project tag.131return;132}133134// Get all the text on the current line. If the line only contains135// whitespace, don't activate: the user is probably typing code or a136// numbered list.137var line = area.value.substring(0, head - 1);138line = line.split('\n');139line = line[line.length - 1];140if (line.match(/^\s+$/)) {141return;142}143144this._cursorHead = head;145this._cursorTail = range.end;146this._pixelHead = JX.TextAreaUtils.getPixelDimensions(147area,148range.start,149range.end);150151var spec = this._map[code];152if (!this._datasources[code]) {153var datasource = new JX.TypeaheadOnDemandSource(spec.datasourceURI);154datasource.listen(155'resultsready',156JX.bind(this, this._onresults, code));157158datasource.setTransformer(JX.bind(this, this._transformresult));159datasource.setSortHandler(160JX.bind(datasource, JX.Prefab.sortHandler, {}));161162this._datasources[code] = datasource;163}164165this._datasource = this._datasources[code];166this._active = code;167168var head_icon = new JX.PHUIXIconView()169.setIcon(spec.headerIcon)170.getNode();171var head_text = spec.headerText;172173var node = this._getPromptNode();174JX.DOM.setContent(node, [head_icon, head_text]);175},176177_transformresult: function(fields) {178var map = JX.Prefab.transformDatasourceResults(fields);179180var icon;181if (map.icon) {182icon = new JX.PHUIXIconView()183.setIcon(map.icon)184.getNode();185}186187var dot;188if (map.availabilityColor) {189dot = JX.$N(190'span',191{192className: 'phui-tag-dot phui-tag-color-' + map.availabilityColor193});194}195196var display = JX.$N('span', {}, [icon, dot, map.displayName]);197JX.DOM.alterClass(display, 'tokenizer-result-closed', !!map.closed);198199map.display = display;200201return map;202},203204_deactivate: function() {205var node = this._getNode();206JX.DOM.hide(node);207208this._active = false;209this._visible = false;210},211212_onkeypress: function(e) {213var r = e.getRawEvent();214215// NOTE: We allow events to continue with "altKey", because you need216// to press Alt to type characters like "@" on a German keyboard layout.217// The cost of misfiring autocompleters is very small since we do not218// eat the keystroke. See T10252.219if (r.metaKey || (r.ctrlKey && !r.altKey)) {220return;221}222223var code = r.charCode;224if (this._map[code]) {225setTimeout(JX.bind(this, this._activate, code), 0);226}227},228229_onresults: function(code, nodes, value, partial) {230// Even if these results are out of date, we still want to fill in the231// result map so we can terminate things later.232if (!partial) {233if (!this._resultMap[code]) {234this._resultMap[code] = {};235}236237var hits = [];238for (var ii = 0; ii < nodes.length; ii++) {239var result = this._datasources[code].getResult(nodes[ii].rel);240if (!result) {241hits = null;242break;243}244245if (!result.autocomplete || !result.autocomplete.length) {246hits = null;247break;248}249250hits.push(result.autocomplete);251}252253if (hits !== null) {254this._resultMap[code][value] = hits;255}256}257258if (code !== this._active) {259return;260}261262if (value !== this._value) {263return;264}265266if (this._isTerminatedString(value)) {267if (this._hasUnrefinableResults(value)) {268this._deactivate();269return;270}271}272273var list = this._getListNode();274JX.DOM.setContent(list, nodes);275276this._listNodes = nodes;277278var old_ref = this._focusRef;279this._clearFocus();280281for (var ii = 0; ii < nodes.length; ii++) {282if (nodes[ii].rel == old_ref) {283this._setFocus(ii);284break;285}286}287288if (this._focus === null && nodes.length) {289this._setFocus(0);290}291292this._redraw();293},294295_setFocus: function(idx) {296if (!this._listNodes[idx]) {297this._clearFocus();298return false;299}300301if (this._focus !== null) {302JX.DOM.alterClass(this._listNodes[this._focus], 'focused', false);303}304305this._focus = idx;306this._focusRef = this._listNodes[idx].rel;307JX.DOM.alterClass(this._listNodes[idx], 'focused', true);308309return true;310},311312_changeFocus: function(delta) {313if (this._focus === null) {314return false;315}316317return this._setFocus(this._focus + delta);318},319320_clearFocus: function() {321this._focus = null;322this._focusRef = null;323},324325_onselect: function (e) {326if (!e.isNormalMouseEvent()) {327// Eat right clicks, control clicks, etc., on the results. These can328// not do anything meaningful and if we let them through they'll blur329// the field and dismiss the results.330e.kill();331return;332}333334var target = e.getNode('typeahead-result');335336for (var ii = 0; ii < this._listNodes.length; ii++) {337if (this._listNodes[ii] === target) {338this._setFocus(ii);339this._autocomplete();340break;341}342}343344this._deactivate();345e.kill();346},347348_getSuffixes: function() {349return [' ', ':', ',', ')'];350},351352_getCancelCharacters: function() {353// The "." character does not cancel because of projects named354// "node.js" or "blog.mycompany.com".355var defaults = ['#', '@', ',', '!', '?', '{', '}'];356357return this._map[this._active].cancel || defaults;358},359360_getTerminators: function() {361return [' ', ':', ',', '.', '!', '?'];362},363364_getIgnoreList: function() {365return this._map[this._active].ignore || [];366},367368_isTerminatedString: function(string) {369var terminators = this._getTerminators();370for (var ii = 0; ii < terminators.length; ii++) {371var term = terminators[ii];372if (string.substring(string.length - term.length) == term) {373return true;374}375}376377return false;378},379380_hasUnrefinableResults: function(query) {381if (!this._resultMap[this._active]) {382return false;383}384385var map = this._resultMap[this._active];386387for (var ii = 1; ii < query.length; ii++) {388var prefix = query.substring(0, ii);389if (map.hasOwnProperty(prefix)) {390var results = map[prefix];391392// If any prefix of the query has no results, the full query also393// has no results so we can not refine them.394if (!results.length) {395return true;396}397398// If there is exactly one match and the it is a prefix of the query,399// we can safely assume the user just typed out the right result400// from memory and doesn't need to refine it.401if (results.length == 1) {402// Strip the first character off, like a "#" or "@".403var result = results[0].substring(1);404405if (query.length >= result.length) {406if (query.substring(0, result.length) === result) {407return true;408}409}410}411}412}413414return false;415},416417_trim: function(str) {418var suffixes = this._getSuffixes();419for (var ii = 0; ii < suffixes.length; ii++) {420if (str.substring(str.length - suffixes[ii].length) == suffixes[ii]) {421str = str.substring(0, str.length - suffixes[ii].length);422}423}424return str;425},426427_update: function(e) {428if (!this._active) {429return;430}431432var special = e.getSpecialKey();433434// Deactivate if the user types escape.435if (special == 'esc') {436this._deactivate();437e.kill();438return;439}440441var area = this._area;442443if (e.getType() == 'keydown') {444if (special == 'up' || special == 'down') {445var delta = (special == 'up') ? -1 : +1;446if (!this._changeFocus(delta)) {447this._deactivate();448}449e.kill();450return;451}452}453454// Deactivate if the user moves the cursor to the left of the assist455// range. For example, they might press the "left" arrow to move the456// cursor to the left, or click in the textarea prior to the active457// range.458var range = JX.TextAreaUtils.getSelectionRange(area);459if (range.start < this._cursorHead) {460this._deactivate();461return;462}463464if (special == 'tab' || special == 'return') {465var r = e.getRawEvent();466if (r.shiftKey && special == 'tab') {467// Don't treat "Shift + Tab" as an autocomplete action. Instead,468// let it through normally so the focus shifts to the previous469// control.470this._deactivate();471return;472}473474// If the user hasn't typed any text yet after typing the character475// which can summon the autocomplete, deactivate and let the keystroke476// through. For example, we hit this when a line ends with an477// autocomplete character and the user is trying to type a newline.478if (range.start == this._cursorHead) {479this._deactivate();480return;481}482483// If we autocomplete, we're done. Otherwise, just eat the event. This484// happens if you type too fast and try to tab complete before results485// load.486if (this._autocomplete()) {487this._deactivate();488}489490e.kill();491return;492}493494// Deactivate if the user moves the cursor to the right of the assist495// range. For example, they might click later in the document. If the user496// is pressing the "right" arrow key, they are not allowed to move the497// cursor beyond the existing end of the text range. If they are pressing498// other keys, assume they're typing and allow the tail to move forward499// one character.500var margin;501if (special == 'right') {502margin = 0;503} else {504margin = 1;505}506507var tail = this._cursorTail;508509if ((range.start > tail + margin) || (range.end > tail + margin)) {510this._deactivate();511return;512}513514this._cursorTail = Math.max(this._cursorTail, range.end);515516var text = area.value.substring(517this._cursorHead,518this._cursorTail);519520var pixels = JX.TextAreaUtils.getPixelDimensions(521area,522range.start,523range.end);524525var x = this._pixelHead.start.x;526var y = Math.max(this._pixelHead.end.y, pixels.end.y) + 24;527528// If the first character after the trigger is a space, just deactivate529// immediately. This occurs if a user types a numbered list using "#".530if (text.length && text[0] == ' ') {531this._deactivate();532return;533}534535// Deactivate immediately if a user types a character that we are536// reasonably sure means they don't want to use the autocomplete. For537// example, "##" is almost certainly a header or monospaced text, not538// a project autocompletion.539var cancels = this._getCancelCharacters();540for (var ii = 0; ii < cancels.length; ii++) {541if (text.indexOf(cancels[ii]) !== -1) {542this._deactivate();543return;544}545}546547var trim = this._trim(text);548549// If this rule has a prefix pattern, like the "[[ document ]]" rule,550// require it match and throw it away before we begin suggesting551// results. The autocomplete remains active, it's just dormant until552// the user gives us more to work with.553var prefix = this._map[this._active].prefix;554if (prefix) {555var pattern = new RegExp(prefix);556if (!trim.match(pattern)) {557// If the prefix pattern can not match the text, deactivate. (This558// check might need to be more careful if we have a more varied559// set of prefixes in the future, but for now they're all a single560// prefix character.)561if (trim.length) {562this._deactivate();563}564return;565}566trim = trim.replace(pattern, '');567trim = trim.trim();568}569570// Store the current value now that we've finished mutating the text.571// This needs to match what we pass to the typeahead datasource.572this._value = trim;573574// Deactivate immediately if the user types an ignored token like ":)",575// the smiley face emoticon. Note that we test against "text", not576// "trim", because the ignore list and suffix list can otherwise577// interact destructively.578var ignore = this._getIgnoreList();579for (ii = 0; ii < ignore.length; ii++) {580if (text.indexOf(ignore[ii]) === 0) {581this._deactivate();582return;583}584}585586// If the input is terminated by a space or another word-terminating587// punctuation mark, we're going to deactivate if the results can not588// be refined by adding more words.589590// The idea is that if you type "@alan ab", you're allowed to keep591// editing "ab" until you type a space, period, or other terminator,592// since you might not be sure how to spell someone's last name or the593// second word of a project.594595// Once you do terminate a word, if the words you have have entered match596// nothing or match only one exact match, we can safely deactivate and597// assume you're just typing text because further words could never598// refine the result set.599600var force;601if (this._isTerminatedString(text)) {602if (this._hasUnrefinableResults(text)) {603this._deactivate();604return;605}606force = true;607} else {608force = false;609}610611this._datasource.didChange(trim, force);612613this._x = x;614this._y = y;615616var hint = trim;617if (hint.length) {618// We only show the autocompleter after the user types at least one619// character. For example, "@" does not trigger it, but "@d" does.620this._visible = true;621} else {622hint = this._getSpec().hintText;623}624625var echo = this._getEchoNode();626JX.DOM.setContent(echo, hint);627628this._redraw();629},630631_redraw: function() {632if (!this._visible) {633return;634}635636var node = this._getNode();637JX.DOM.show(node);638639var p = new JX.Vector(this._x, this._y);640var s = JX.Vector.getScroll();641var v = JX.Vector.getViewport();642643// If the menu would run off the bottom of the screen when showing the644// maximum number of possible choices, put it above instead. We're doing645// this based on the maximum size so the menu doesn't jump up and down646// as results arrive.647648var option_height = 30;649var extra_margin = 24;650if ((s.y + v.y) < (p.y + (5 * option_height) + extra_margin)) {651var d = JX.Vector.getDim(node);652p.y = p.y - d.y - 36;653}654655p.setPos(node);656},657658_autocomplete: function() {659if (this._focus === null) {660return false;661}662663var area = this._area;664var head = this._cursorHead;665var tail = this._cursorTail;666667var text = area.value;668669var ref = this._focusRef;670var result = this._datasource.getResult(ref);671if (!result) {672return false;673}674675ref = result.autocomplete;676if (!ref || !ref.length) {677return false;678}679680// If the user types a string like "@username:" (with a trailing colon),681// then presses tab or return to pick the completion, don't destroy the682// trailing character.683var suffixes = this._getSuffixes();684var value = this._value;685var found_suffix = false;686for (var ii = 0; ii < suffixes.length; ii++) {687var last = value.substring(value.length - suffixes[ii].length);688if (last == suffixes[ii]) {689ref += suffixes[ii];690found_suffix = true;691break;692}693}694695// If we didn't find an existing suffix, add a space.696if (!found_suffix) {697ref = ref + ' ';698}699700area.value = text.substring(0, head - 1) + ref + text.substring(tail);701702var end = head + ref.length;703JX.TextAreaUtils.setSelectionRange(area, end, end);704705return true;706},707708_getNode: function() {709if (!this._node) {710var head = this._getHeadNode();711var list = this._getListNode();712713this._node = JX.$N(714'div',715{716className: 'phuix-autocomplete',717style: {718display: 'none'719}720},721[head, list]);722723JX.DOM.hide(this._node);724725document.body.appendChild(this._node);726}727return this._node;728},729730_getHeadNode: function() {731if (!this._headNode) {732this._headNode = JX.$N(733'div',734{735className: 'phuix-autocomplete-head'736},737[738this._getPromptNode(),739this._getEchoNode()740]);741}742743return this._headNode;744},745746_getPromptNode: function() {747if (!this._promptNode) {748this._promptNode = JX.$N(749'span',750{751className: 'phuix-autocomplete-prompt',752});753}754return this._promptNode;755},756757_getEchoNode: function() {758if (!this._echoNode) {759this._echoNode = JX.$N(760'span',761{762className: 'phuix-autocomplete-echo'763});764}765return this._echoNode;766},767768_getListNode: function() {769if (!this._listNode) {770this._listNode = JX.$N(771'div',772{773className: 'phuix-autocomplete-list'774});775}776return this._listNode;777}778779}780781});782783784