Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/webroot/rsrc/js/phuix/PHUIXAutocomplete.js
12241 views
1
/**
2
* @provides phuix-autocomplete
3
* @requires javelin-install
4
* javelin-dom
5
* phuix-icon-view
6
* phabricator-prefab
7
*/
8
9
JX.install('PHUIXAutocomplete', {
10
11
construct: function() {
12
this._map = {};
13
this._datasources = {};
14
this._listNodes = [];
15
this._resultMap = {};
16
},
17
18
members: {
19
_area: null,
20
_active: false,
21
_cursorHead: null,
22
_cursorTail: null,
23
_pixelHead: null,
24
_pixelTail: null,
25
_map: null,
26
_datasource: null,
27
_datasources: null,
28
_value: null,
29
_node: null,
30
_echoNode: null,
31
_listNode: null,
32
_promptNode: null,
33
_focus: null,
34
_focusRef: null,
35
_listNodes: null,
36
_x: null,
37
_y: null,
38
_visible: false,
39
_resultMap: null,
40
41
setArea: function(area) {
42
this._area = area;
43
return this;
44
},
45
46
addAutocomplete: function(code, spec) {
47
this._map[code] = spec;
48
return this;
49
},
50
51
start: function() {
52
var area = this._area;
53
54
JX.DOM.listen(area, 'keypress', null, JX.bind(this, this._onkeypress));
55
56
JX.DOM.listen(
57
area,
58
['click', 'keyup', 'keydown', 'keypress'],
59
null,
60
JX.bind(this, this._update));
61
62
var select = JX.bind(this, this._onselect);
63
JX.DOM.listen(this._getNode(), 'mousedown', 'typeahead-result', select);
64
65
var device = JX.bind(this, this._ondevice);
66
JX.Stratcom.listen('phabricator-device-change', null, device);
67
68
// When the user clicks away from the textarea, deactivate.
69
var deactivate = JX.bind(this, this._deactivate);
70
JX.DOM.listen(area, 'blur', null, deactivate);
71
},
72
73
_getSpec: function() {
74
return this._map[this._active];
75
},
76
77
_ondevice: function() {
78
if (JX.Device.getDevice() != 'desktop') {
79
this._deactivate();
80
}
81
},
82
83
_activate: function(code) {
84
if (JX.Device.getDevice() != 'desktop') {
85
return;
86
}
87
88
if (!this._map[code]) {
89
return;
90
}
91
92
var area = this._area;
93
var range = JX.TextAreaUtils.getSelectionRange(area);
94
95
// Check the character immediately before the trigger character. We'll
96
// only activate the typeahead if it's something that we think a user
97
// might reasonably want to autocomplete after, like a space, newline,
98
// or open parenthesis. For example, if a user types "alincoln@",
99
// the prior letter will be the last "n" in "alincoln". They are probably
100
// typing an email address, not a username, so we don't activate the
101
// autocomplete.
102
var head = range.start;
103
var prior;
104
if (head > 1) {
105
prior = area.value.substring(head - 2, head - 1);
106
} else {
107
prior = '<start>';
108
}
109
110
// If this is a repeating sequence and the previous character is the
111
// same as the one the user just typed, like "((", don't reactivate.
112
if (prior === String.fromCharCode(code)) {
113
return;
114
}
115
116
switch (prior) {
117
case '<start>':
118
case ' ':
119
case '\n':
120
case '\t':
121
case '(': // Might be "(@username, what do you think?)".
122
case '-': // Might be an unnumbered list.
123
case '.': // Might be a numbered list.
124
case '|': // Might be a table cell.
125
case '>': // Might be a blockquote.
126
case '!': // Might be a blockquote attribution line.
127
// We'll let these autocomplete.
128
break;
129
default:
130
// We bail out on anything else, since the user is probably not
131
// typing a username or project tag.
132
return;
133
}
134
135
// Get all the text on the current line. If the line only contains
136
// whitespace, don't activate: the user is probably typing code or a
137
// numbered list.
138
var line = area.value.substring(0, head - 1);
139
line = line.split('\n');
140
line = line[line.length - 1];
141
if (line.match(/^\s+$/)) {
142
return;
143
}
144
145
this._cursorHead = head;
146
this._cursorTail = range.end;
147
this._pixelHead = JX.TextAreaUtils.getPixelDimensions(
148
area,
149
range.start,
150
range.end);
151
152
var spec = this._map[code];
153
if (!this._datasources[code]) {
154
var datasource = new JX.TypeaheadOnDemandSource(spec.datasourceURI);
155
datasource.listen(
156
'resultsready',
157
JX.bind(this, this._onresults, code));
158
159
datasource.setTransformer(JX.bind(this, this._transformresult));
160
datasource.setSortHandler(
161
JX.bind(datasource, JX.Prefab.sortHandler, {}));
162
163
this._datasources[code] = datasource;
164
}
165
166
this._datasource = this._datasources[code];
167
this._active = code;
168
169
var head_icon = new JX.PHUIXIconView()
170
.setIcon(spec.headerIcon)
171
.getNode();
172
var head_text = spec.headerText;
173
174
var node = this._getPromptNode();
175
JX.DOM.setContent(node, [head_icon, head_text]);
176
},
177
178
_transformresult: function(fields) {
179
var map = JX.Prefab.transformDatasourceResults(fields);
180
181
var icon;
182
if (map.icon) {
183
icon = new JX.PHUIXIconView()
184
.setIcon(map.icon)
185
.getNode();
186
}
187
188
var dot;
189
if (map.availabilityColor) {
190
dot = JX.$N(
191
'span',
192
{
193
className: 'phui-tag-dot phui-tag-color-' + map.availabilityColor
194
});
195
}
196
197
var display = JX.$N('span', {}, [icon, dot, map.displayName]);
198
JX.DOM.alterClass(display, 'tokenizer-result-closed', !!map.closed);
199
200
map.display = display;
201
202
return map;
203
},
204
205
_deactivate: function() {
206
var node = this._getNode();
207
JX.DOM.hide(node);
208
209
this._active = false;
210
this._visible = false;
211
},
212
213
_onkeypress: function(e) {
214
var r = e.getRawEvent();
215
216
// NOTE: We allow events to continue with "altKey", because you need
217
// to press Alt to type characters like "@" on a German keyboard layout.
218
// The cost of misfiring autocompleters is very small since we do not
219
// eat the keystroke. See T10252.
220
if (r.metaKey || (r.ctrlKey && !r.altKey)) {
221
return;
222
}
223
224
var code = r.charCode;
225
if (this._map[code]) {
226
setTimeout(JX.bind(this, this._activate, code), 0);
227
}
228
},
229
230
_onresults: function(code, nodes, value, partial) {
231
// Even if these results are out of date, we still want to fill in the
232
// result map so we can terminate things later.
233
if (!partial) {
234
if (!this._resultMap[code]) {
235
this._resultMap[code] = {};
236
}
237
238
var hits = [];
239
for (var ii = 0; ii < nodes.length; ii++) {
240
var result = this._datasources[code].getResult(nodes[ii].rel);
241
if (!result) {
242
hits = null;
243
break;
244
}
245
246
if (!result.autocomplete || !result.autocomplete.length) {
247
hits = null;
248
break;
249
}
250
251
hits.push(result.autocomplete);
252
}
253
254
if (hits !== null) {
255
this._resultMap[code][value] = hits;
256
}
257
}
258
259
if (code !== this._active) {
260
return;
261
}
262
263
if (value !== this._value) {
264
return;
265
}
266
267
if (this._isTerminatedString(value)) {
268
if (this._hasUnrefinableResults(value)) {
269
this._deactivate();
270
return;
271
}
272
}
273
274
var list = this._getListNode();
275
JX.DOM.setContent(list, nodes);
276
277
this._listNodes = nodes;
278
279
var old_ref = this._focusRef;
280
this._clearFocus();
281
282
for (var ii = 0; ii < nodes.length; ii++) {
283
if (nodes[ii].rel == old_ref) {
284
this._setFocus(ii);
285
break;
286
}
287
}
288
289
if (this._focus === null && nodes.length) {
290
this._setFocus(0);
291
}
292
293
this._redraw();
294
},
295
296
_setFocus: function(idx) {
297
if (!this._listNodes[idx]) {
298
this._clearFocus();
299
return false;
300
}
301
302
if (this._focus !== null) {
303
JX.DOM.alterClass(this._listNodes[this._focus], 'focused', false);
304
}
305
306
this._focus = idx;
307
this._focusRef = this._listNodes[idx].rel;
308
JX.DOM.alterClass(this._listNodes[idx], 'focused', true);
309
310
return true;
311
},
312
313
_changeFocus: function(delta) {
314
if (this._focus === null) {
315
return false;
316
}
317
318
return this._setFocus(this._focus + delta);
319
},
320
321
_clearFocus: function() {
322
this._focus = null;
323
this._focusRef = null;
324
},
325
326
_onselect: function (e) {
327
if (!e.isNormalMouseEvent()) {
328
// Eat right clicks, control clicks, etc., on the results. These can
329
// not do anything meaningful and if we let them through they'll blur
330
// the field and dismiss the results.
331
e.kill();
332
return;
333
}
334
335
var target = e.getNode('typeahead-result');
336
337
for (var ii = 0; ii < this._listNodes.length; ii++) {
338
if (this._listNodes[ii] === target) {
339
this._setFocus(ii);
340
this._autocomplete();
341
break;
342
}
343
}
344
345
this._deactivate();
346
e.kill();
347
},
348
349
_getSuffixes: function() {
350
return [' ', ':', ',', ')'];
351
},
352
353
_getCancelCharacters: function() {
354
// The "." character does not cancel because of projects named
355
// "node.js" or "blog.mycompany.com".
356
var defaults = ['#', '@', ',', '!', '?', '{', '}'];
357
358
return this._map[this._active].cancel || defaults;
359
},
360
361
_getTerminators: function() {
362
return [' ', ':', ',', '.', '!', '?'];
363
},
364
365
_getIgnoreList: function() {
366
return this._map[this._active].ignore || [];
367
},
368
369
_isTerminatedString: function(string) {
370
var terminators = this._getTerminators();
371
for (var ii = 0; ii < terminators.length; ii++) {
372
var term = terminators[ii];
373
if (string.substring(string.length - term.length) == term) {
374
return true;
375
}
376
}
377
378
return false;
379
},
380
381
_hasUnrefinableResults: function(query) {
382
if (!this._resultMap[this._active]) {
383
return false;
384
}
385
386
var map = this._resultMap[this._active];
387
388
for (var ii = 1; ii < query.length; ii++) {
389
var prefix = query.substring(0, ii);
390
if (map.hasOwnProperty(prefix)) {
391
var results = map[prefix];
392
393
// If any prefix of the query has no results, the full query also
394
// has no results so we can not refine them.
395
if (!results.length) {
396
return true;
397
}
398
399
// If there is exactly one match and the it is a prefix of the query,
400
// we can safely assume the user just typed out the right result
401
// from memory and doesn't need to refine it.
402
if (results.length == 1) {
403
// Strip the first character off, like a "#" or "@".
404
var result = results[0].substring(1);
405
406
if (query.length >= result.length) {
407
if (query.substring(0, result.length) === result) {
408
return true;
409
}
410
}
411
}
412
}
413
}
414
415
return false;
416
},
417
418
_trim: function(str) {
419
var suffixes = this._getSuffixes();
420
for (var ii = 0; ii < suffixes.length; ii++) {
421
if (str.substring(str.length - suffixes[ii].length) == suffixes[ii]) {
422
str = str.substring(0, str.length - suffixes[ii].length);
423
}
424
}
425
return str;
426
},
427
428
_update: function(e) {
429
if (!this._active) {
430
return;
431
}
432
433
var special = e.getSpecialKey();
434
435
// Deactivate if the user types escape.
436
if (special == 'esc') {
437
this._deactivate();
438
e.kill();
439
return;
440
}
441
442
var area = this._area;
443
444
if (e.getType() == 'keydown') {
445
if (special == 'up' || special == 'down') {
446
var delta = (special == 'up') ? -1 : +1;
447
if (!this._changeFocus(delta)) {
448
this._deactivate();
449
}
450
e.kill();
451
return;
452
}
453
}
454
455
// Deactivate if the user moves the cursor to the left of the assist
456
// range. For example, they might press the "left" arrow to move the
457
// cursor to the left, or click in the textarea prior to the active
458
// range.
459
var range = JX.TextAreaUtils.getSelectionRange(area);
460
if (range.start < this._cursorHead) {
461
this._deactivate();
462
return;
463
}
464
465
if (special == 'tab' || special == 'return') {
466
var r = e.getRawEvent();
467
if (r.shiftKey && special == 'tab') {
468
// Don't treat "Shift + Tab" as an autocomplete action. Instead,
469
// let it through normally so the focus shifts to the previous
470
// control.
471
this._deactivate();
472
return;
473
}
474
475
// If the user hasn't typed any text yet after typing the character
476
// which can summon the autocomplete, deactivate and let the keystroke
477
// through. For example, we hit this when a line ends with an
478
// autocomplete character and the user is trying to type a newline.
479
if (range.start == this._cursorHead) {
480
this._deactivate();
481
return;
482
}
483
484
// If we autocomplete, we're done. Otherwise, just eat the event. This
485
// happens if you type too fast and try to tab complete before results
486
// load.
487
if (this._autocomplete()) {
488
this._deactivate();
489
}
490
491
e.kill();
492
return;
493
}
494
495
// Deactivate if the user moves the cursor to the right of the assist
496
// range. For example, they might click later in the document. If the user
497
// is pressing the "right" arrow key, they are not allowed to move the
498
// cursor beyond the existing end of the text range. If they are pressing
499
// other keys, assume they're typing and allow the tail to move forward
500
// one character.
501
var margin;
502
if (special == 'right') {
503
margin = 0;
504
} else {
505
margin = 1;
506
}
507
508
var tail = this._cursorTail;
509
510
if ((range.start > tail + margin) || (range.end > tail + margin)) {
511
this._deactivate();
512
return;
513
}
514
515
this._cursorTail = Math.max(this._cursorTail, range.end);
516
517
var text = area.value.substring(
518
this._cursorHead,
519
this._cursorTail);
520
521
var pixels = JX.TextAreaUtils.getPixelDimensions(
522
area,
523
range.start,
524
range.end);
525
526
var x = this._pixelHead.start.x;
527
var y = Math.max(this._pixelHead.end.y, pixels.end.y) + 24;
528
529
// If the first character after the trigger is a space, just deactivate
530
// immediately. This occurs if a user types a numbered list using "#".
531
if (text.length && text[0] == ' ') {
532
this._deactivate();
533
return;
534
}
535
536
// Deactivate immediately if a user types a character that we are
537
// reasonably sure means they don't want to use the autocomplete. For
538
// example, "##" is almost certainly a header or monospaced text, not
539
// a project autocompletion.
540
var cancels = this._getCancelCharacters();
541
for (var ii = 0; ii < cancels.length; ii++) {
542
if (text.indexOf(cancels[ii]) !== -1) {
543
this._deactivate();
544
return;
545
}
546
}
547
548
var trim = this._trim(text);
549
550
// If this rule has a prefix pattern, like the "[[ document ]]" rule,
551
// require it match and throw it away before we begin suggesting
552
// results. The autocomplete remains active, it's just dormant until
553
// the user gives us more to work with.
554
var prefix = this._map[this._active].prefix;
555
if (prefix) {
556
var pattern = new RegExp(prefix);
557
if (!trim.match(pattern)) {
558
// If the prefix pattern can not match the text, deactivate. (This
559
// check might need to be more careful if we have a more varied
560
// set of prefixes in the future, but for now they're all a single
561
// prefix character.)
562
if (trim.length) {
563
this._deactivate();
564
}
565
return;
566
}
567
trim = trim.replace(pattern, '');
568
trim = trim.trim();
569
}
570
571
// Store the current value now that we've finished mutating the text.
572
// This needs to match what we pass to the typeahead datasource.
573
this._value = trim;
574
575
// Deactivate immediately if the user types an ignored token like ":)",
576
// the smiley face emoticon. Note that we test against "text", not
577
// "trim", because the ignore list and suffix list can otherwise
578
// interact destructively.
579
var ignore = this._getIgnoreList();
580
for (ii = 0; ii < ignore.length; ii++) {
581
if (text.indexOf(ignore[ii]) === 0) {
582
this._deactivate();
583
return;
584
}
585
}
586
587
// If the input is terminated by a space or another word-terminating
588
// punctuation mark, we're going to deactivate if the results can not
589
// be refined by adding more words.
590
591
// The idea is that if you type "@alan ab", you're allowed to keep
592
// editing "ab" until you type a space, period, or other terminator,
593
// since you might not be sure how to spell someone's last name or the
594
// second word of a project.
595
596
// Once you do terminate a word, if the words you have have entered match
597
// nothing or match only one exact match, we can safely deactivate and
598
// assume you're just typing text because further words could never
599
// refine the result set.
600
601
var force;
602
if (this._isTerminatedString(text)) {
603
if (this._hasUnrefinableResults(text)) {
604
this._deactivate();
605
return;
606
}
607
force = true;
608
} else {
609
force = false;
610
}
611
612
this._datasource.didChange(trim, force);
613
614
this._x = x;
615
this._y = y;
616
617
var hint = trim;
618
if (hint.length) {
619
// We only show the autocompleter after the user types at least one
620
// character. For example, "@" does not trigger it, but "@d" does.
621
this._visible = true;
622
} else {
623
hint = this._getSpec().hintText;
624
}
625
626
var echo = this._getEchoNode();
627
JX.DOM.setContent(echo, hint);
628
629
this._redraw();
630
},
631
632
_redraw: function() {
633
if (!this._visible) {
634
return;
635
}
636
637
var node = this._getNode();
638
JX.DOM.show(node);
639
640
var p = new JX.Vector(this._x, this._y);
641
var s = JX.Vector.getScroll();
642
var v = JX.Vector.getViewport();
643
644
// If the menu would run off the bottom of the screen when showing the
645
// maximum number of possible choices, put it above instead. We're doing
646
// this based on the maximum size so the menu doesn't jump up and down
647
// as results arrive.
648
649
var option_height = 30;
650
var extra_margin = 24;
651
if ((s.y + v.y) < (p.y + (5 * option_height) + extra_margin)) {
652
var d = JX.Vector.getDim(node);
653
p.y = p.y - d.y - 36;
654
}
655
656
p.setPos(node);
657
},
658
659
_autocomplete: function() {
660
if (this._focus === null) {
661
return false;
662
}
663
664
var area = this._area;
665
var head = this._cursorHead;
666
var tail = this._cursorTail;
667
668
var text = area.value;
669
670
var ref = this._focusRef;
671
var result = this._datasource.getResult(ref);
672
if (!result) {
673
return false;
674
}
675
676
ref = result.autocomplete;
677
if (!ref || !ref.length) {
678
return false;
679
}
680
681
// If the user types a string like "@username:" (with a trailing colon),
682
// then presses tab or return to pick the completion, don't destroy the
683
// trailing character.
684
var suffixes = this._getSuffixes();
685
var value = this._value;
686
var found_suffix = false;
687
for (var ii = 0; ii < suffixes.length; ii++) {
688
var last = value.substring(value.length - suffixes[ii].length);
689
if (last == suffixes[ii]) {
690
ref += suffixes[ii];
691
found_suffix = true;
692
break;
693
}
694
}
695
696
// If we didn't find an existing suffix, add a space.
697
if (!found_suffix) {
698
ref = ref + ' ';
699
}
700
701
area.value = text.substring(0, head - 1) + ref + text.substring(tail);
702
703
var end = head + ref.length;
704
JX.TextAreaUtils.setSelectionRange(area, end, end);
705
706
return true;
707
},
708
709
_getNode: function() {
710
if (!this._node) {
711
var head = this._getHeadNode();
712
var list = this._getListNode();
713
714
this._node = JX.$N(
715
'div',
716
{
717
className: 'phuix-autocomplete',
718
style: {
719
display: 'none'
720
}
721
},
722
[head, list]);
723
724
JX.DOM.hide(this._node);
725
726
document.body.appendChild(this._node);
727
}
728
return this._node;
729
},
730
731
_getHeadNode: function() {
732
if (!this._headNode) {
733
this._headNode = JX.$N(
734
'div',
735
{
736
className: 'phuix-autocomplete-head'
737
},
738
[
739
this._getPromptNode(),
740
this._getEchoNode()
741
]);
742
}
743
744
return this._headNode;
745
},
746
747
_getPromptNode: function() {
748
if (!this._promptNode) {
749
this._promptNode = JX.$N(
750
'span',
751
{
752
className: 'phuix-autocomplete-prompt',
753
});
754
}
755
return this._promptNode;
756
},
757
758
_getEchoNode: function() {
759
if (!this._echoNode) {
760
this._echoNode = JX.$N(
761
'span',
762
{
763
className: 'phuix-autocomplete-echo'
764
});
765
}
766
return this._echoNode;
767
},
768
769
_getListNode: function() {
770
if (!this._listNode) {
771
this._listNode = JX.$N(
772
'div',
773
{
774
className: 'phuix-autocomplete-list'
775
});
776
}
777
return this._listNode;
778
}
779
780
}
781
782
});
783
784