1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-26 15:30:58 +01:00

Add a basic remarkup typeahead for users and projects

Summary: Ref T3725. This probably has 900,000 bugs. This will need updates for subprojects/milestones.

Test Plan:
  - Tested very gently in Safari, Firefox and Chrome.
  - Reasonable inputs appear to work.
  - Clicking, escape, tab, return, arrow keys work OK?

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T3725

Differential Revision: https://secure.phabricator.com/D15029
This commit is contained in:
epriestley 2016-01-15 04:25:22 -08:00
parent aadc1b7bf0
commit 5d6dd7df7d
10 changed files with 648 additions and 35 deletions

View file

@ -7,8 +7,8 @@
*/
return array(
'names' => array(
'core.pkg.css' => '1eed0b4f',
'core.pkg.js' => '6ae03393',
'core.pkg.css' => 'f30d5cbd',
'core.pkg.js' => '1f5f365a',
'darkconsole.pkg.js' => 'e7393ebb',
'differential.pkg.css' => '2de124c9',
'differential.pkg.js' => 'f83532f8',
@ -102,9 +102,9 @@ return array(
'rsrc/css/application/tokens/tokens.css' => '3d0f239e',
'rsrc/css/application/uiexample/example.css' => '528b19de',
'rsrc/css/core/core.css' => 'a76cefc9',
'rsrc/css/core/remarkup.css' => 'b6ad82e4',
'rsrc/css/core/remarkup.css' => 'b748dc17',
'rsrc/css/core/syntax.css' => '9fd11da8',
'rsrc/css/core/z-index.css' => '57ddcaa2',
'rsrc/css/core/z-index.css' => 'a36a45da',
'rsrc/css/diviner/diviner-shared.css' => 'aa3656aa',
'rsrc/css/font/font-aleo.css' => '8bdb2835',
'rsrc/css/font/font-awesome.css' => 'c43323c5',
@ -457,7 +457,7 @@ return array(
'rsrc/js/core/KeyboardShortcutManager.js' => 'c1700f6f',
'rsrc/js/core/MultirowRowManager.js' => 'b5d57730',
'rsrc/js/core/Notification.js' => 'ccf1cbf8',
'rsrc/js/core/Prefab.js' => '666c80c5',
'rsrc/js/core/Prefab.js' => 'a15cbd65',
'rsrc/js/core/ShapedRequest.js' => '7cbe244b',
'rsrc/js/core/TextAreaUtils.js' => '9e54692d',
'rsrc/js/core/Title.js' => 'df5e11d2',
@ -487,7 +487,7 @@ return array(
'rsrc/js/core/behavior-object-selector.js' => '49b73b36',
'rsrc/js/core/behavior-oncopy.js' => '2926fff2',
'rsrc/js/core/behavior-phabricator-nav.js' => '56a1ca03',
'rsrc/js/core/behavior-phabricator-remarkup-assist.js' => 'b60b6d9b',
'rsrc/js/core/behavior-phabricator-remarkup-assist.js' => '340c8eff',
'rsrc/js/core/behavior-refresh-csrf.js' => 'ab2f381b',
'rsrc/js/core/behavior-remarkup-preview.js' => '4b700e9e',
'rsrc/js/core/behavior-reorder-applications.js' => '76b9fc3e',
@ -506,6 +506,7 @@ return array(
'rsrc/js/phui/behavior-phui-object-box-tabs.js' => '2bfa2836',
'rsrc/js/phuix/PHUIXActionListView.js' => 'b5c256b8',
'rsrc/js/phuix/PHUIXActionView.js' => '8cf6d262',
'rsrc/js/phuix/PHUIXAutocomplete.js' => 'c5f5e42f',
'rsrc/js/phuix/PHUIXDropdownMenu.js' => 'bd4c8dca',
'rsrc/js/phuix/PHUIXFormControl.js' => '8fba1997',
'rsrc/js/phuix/PHUIXIconView.js' => 'bff6884b',
@ -639,7 +640,7 @@ return array(
'javelin-behavior-phabricator-notification-example' => '8ce821c5',
'javelin-behavior-phabricator-object-selector' => '49b73b36',
'javelin-behavior-phabricator-oncopy' => '2926fff2',
'javelin-behavior-phabricator-remarkup-assist' => 'b60b6d9b',
'javelin-behavior-phabricator-remarkup-assist' => '340c8eff',
'javelin-behavior-phabricator-reveal-content' => '60821bc7',
'javelin-behavior-phabricator-search-typeahead' => '0b7a4f6e',
'javelin-behavior-phabricator-show-older-transactions' => 'dbbf48b6',
@ -758,8 +759,8 @@ return array(
'phabricator-notification-menu-css' => 'f31c0bde',
'phabricator-object-selector-css' => '85ee8ce6',
'phabricator-phtize' => 'd254d646',
'phabricator-prefab' => '666c80c5',
'phabricator-remarkup-css' => 'b6ad82e4',
'phabricator-prefab' => 'a15cbd65',
'phabricator-remarkup-css' => 'b748dc17',
'phabricator-search-results-css' => '7dea472c',
'phabricator-shaped-request' => '7cbe244b',
'phabricator-side-menu-view-css' => '91b7a42c',
@ -780,7 +781,7 @@ return array(
'phabricator-uiexample-reactor-select' => 'a155550f',
'phabricator-uiexample-reactor-sendclass' => '1def2711',
'phabricator-uiexample-reactor-sendproperties' => 'b1f0ccee',
'phabricator-zindex-css' => '57ddcaa2',
'phabricator-zindex-css' => 'a36a45da',
'phame-css' => 'dac8fdf2',
'pholio-css' => '95174bdd',
'pholio-edit-css' => '3ad9d1ee',
@ -833,6 +834,7 @@ return array(
'phui-workpanel-view-css' => 'adec7699',
'phuix-action-list-view' => 'b5c256b8',
'phuix-action-view' => '8cf6d262',
'phuix-autocomplete' => 'c5f5e42f',
'phuix-dropdown-menu' => 'bd4c8dca',
'phuix-form-control-view' => '8fba1997',
'phuix-icon-view' => 'bff6884b',
@ -1050,6 +1052,16 @@ return array(
'331b1611' => array(
'javelin-install',
),
'340c8eff' => array(
'javelin-behavior',
'javelin-stratcom',
'javelin-dom',
'phabricator-phtize',
'phabricator-textareautils',
'javelin-workflow',
'javelin-vector',
'phuix-autocomplete',
),
'3ab51e2c' => array(
'javelin-behavior',
'javelin-behavior-device',
@ -1293,18 +1305,6 @@ return array(
'javelin-vector',
'differential-inline-comment-editor',
),
'666c80c5' => array(
'javelin-install',
'javelin-util',
'javelin-dom',
'javelin-typeahead',
'javelin-tokenizer',
'javelin-typeahead-preloaded-source',
'javelin-typeahead-ondemand-source',
'javelin-dom',
'javelin-stratcom',
'javelin-util',
),
'66dd6e9e' => array(
'javelin-behavior',
'javelin-behavior-device',
@ -1587,6 +1587,18 @@ return array(
'javelin-dom',
'javelin-reactor-dom',
),
'a15cbd65' => array(
'javelin-install',
'javelin-util',
'javelin-dom',
'javelin-typeahead',
'javelin-tokenizer',
'javelin-typeahead-preloaded-source',
'javelin-typeahead-ondemand-source',
'javelin-dom',
'javelin-stratcom',
'javelin-util',
),
'a16ec1c6' => array(
'javelin-install',
'javelin-dom',
@ -1742,15 +1754,6 @@ return array(
'javelin-dom',
'javelin-util',
),
'b60b6d9b' => array(
'javelin-behavior',
'javelin-stratcom',
'javelin-dom',
'phabricator-phtize',
'phabricator-textareautils',
'javelin-workflow',
'javelin-vector',
),
'b6993408' => array(
'javelin-behavior',
'javelin-stratcom',
@ -1794,6 +1797,12 @@ return array(
'javelin-dom',
'javelin-vector',
),
'c5f5e42f' => array(
'javelin-install',
'javelin-dom',
'phuix-icon-view',
'phabricator-prefab',
),
'c6f720ff' => array(
'javelin-install',
'javelin-dom',

View file

@ -59,12 +59,15 @@ final class PhabricatorPeopleDatasource
$closed = pht('Mailing List');
}
$username = $user->getUsername();
$result = id(new PhabricatorTypeaheadResult())
->setName($user->getFullName())
->setURI('/p/'.$user->getUsername())
->setURI('/p/'.$username.'/')
->setPHID($user->getPHID())
->setPriorityString($user->getUsername())
->setPriorityString($username)
->setPriorityType('user')
->setAutocomplete('@'.$username)
->setClosed($closed);
if ($user->getIsMailingList()) {

View file

@ -65,13 +65,18 @@ final class PhabricatorProjectDatasource
->setName($all_strings)
->setDisplayName($proj->getName())
->setDisplayType(pht('Project'))
->setURI('/tag/'.$proj->getPrimarySlug().'/')
->setURI($proj->getURI())
->setPHID($proj->getPHID())
->setIcon($proj->getDisplayIconIcon())
->setColor($proj->getColor())
->setPriorityType('proj')
->setClosed($closed);
$slug = $proj->getPrimarySlug();
if (strlen($slug)) {
$proj_result->setAutocomplete('#'.$slug);
}
$proj_result->setImageURI($proj->getProfileImageURI());
$results[] = $proj_result;

View file

@ -16,6 +16,7 @@ final class PhabricatorTypeaheadResult extends Phobject {
private $closed;
private $tokenType;
private $unique;
private $autocomplete;
public function setIcon($icon) {
$this->icon = $icon;
@ -114,6 +115,15 @@ final class PhabricatorTypeaheadResult extends Phobject {
return $this->color;
}
public function setAutocomplete($autocomplete) {
$this->autocomplete = $autocomplete;
return $this;
}
public function getAutocomplete() {
return $this->autocomplete;
}
public function getSortKey() {
// Put unique results (special parameter functions) ahead of other
// results.
@ -142,6 +152,7 @@ final class PhabricatorTypeaheadResult extends Phobject {
$this->color,
$this->tokenType,
$this->unique ? 1 : null,
$this->autocomplete,
);
while (end($data) === null) {
array_pop($data);

View file

@ -44,6 +44,9 @@ final class PhabricatorRemarkupControl extends AphrontFormTextAreaControl {
$root_id = celerity_generate_unique_node_id();
$user_datasource = new PhabricatorPeopleDatasource();
$proj_datasource = new PhabricatorProjectDatasource();
Javelin::initBehavior(
'phabricator-remarkup-assist',
array(
@ -59,6 +62,20 @@ final class PhabricatorRemarkupControl extends AphrontFormTextAreaControl {
),
'disabled' => $this->getDisabled(),
'rootID' => $root_id,
'autocompleteMap' => (object)array(
64 => array( // "@"
'datasourceURI' => $user_datasource->getDatasourceURI(),
'headerIcon' => 'fa-user',
'headerText' => pht('Find User:'),
'hintText' => $user_datasource->getPlaceholderText(),
),
35 => array( // "#"
'datasourceURI' => $proj_datasource->getDatasourceURI(),
'headerIcon' => 'fa-briefcase',
'headerText' => pht('Find Project:'),
'hintText' => $proj_datasource->getPlaceholderText(),
),
),
));
Javelin::initBehavior('phabricator-tooltips', array());

View file

@ -561,3 +561,52 @@ var.remarkup-assist-textarea {
.device .remarkup-assist-nodevice {
display: none;
}
.phuix-autocomplete {
position: absolute;
width: 300px;
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.300);
background: #ffffff;
border: 1px solid {$blueborder};
}
.phuix-autocomplete-head {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 8px;
background: {$lightgreybackground};
color: {$greytext};
}
.phuix-autocomplete-head .phui-icon-view {
margin-right: 4px;
color: {$greytext};
}
.phuix-autocomplete-echo {
margin-left: 4px;
color: {$lightgreytext};
}
.phuix-autocomplete-list a.jx-result {
display: block;
padding: 4px 8px;
font-size: {$normalfontsize};
border-top: 1px solid {$hoverborder};
color: {$darkgreytext};
}
.phuix-autocomplete-list a.jx-result .phui-icon-view {
margin-right: 4px;
}
.phuix-autocomplete-list a.jx-result:hover {
text-decoration: none;
background: {$hoverblue};
}
.phuix-autocomplete-list a.jx-result.focused,
.phuix-autocomplete-list a.jx-result.focused:hover {
background: {$hoverblue};
}

View file

@ -150,6 +150,10 @@ div.jx-typeahead-results {
z-index: 20;
}
.phuix-autocomplete {
z-index: 21;
}
.phuix-dropdown-menu {
z-index: 32;
}

View file

@ -325,7 +325,8 @@ JX.install('Prefab', {
sprite: fields[10],
color: fields[11],
tokenType: fields[12],
unique: fields[13] || false
unique: fields[13] || false,
autocomplete: fields[14]
};
},

View file

@ -7,6 +7,7 @@
* phabricator-textareautils
* javelin-workflow
* javelin-vector
* phuix-autocomplete
*/
JX.behavior('phabricator-remarkup-assist', function(config) {
@ -293,4 +294,13 @@ JX.behavior('phabricator-remarkup-assist', function(config) {
assist(area, data.action, root, e.getNode('remarkup-assist'));
});
var autocomplete = new JX.PHUIXAutocomplete()
.setArea(area);
for (var k in config.autocompleteMap) {
autocomplete.addAutocomplete(k, config.autocompleteMap[k]);
}
autocomplete.start();
});

View file

@ -0,0 +1,504 @@
/**
* @provides phuix-autocomplete
* @requires javelin-install
* javelin-dom
* phuix-icon-view
* phabricator-prefab
*/
JX.install('PHUIXAutocomplete', {
construct: function() {
this._map = {};
this._datasources = {};
this._listNodes = [];
},
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,
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(), 'click', '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>';
}
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.
// 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;
}
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));
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;
},
_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) {
if (code !== this._active) {
return;
}
if (value !== this._value) {
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);
}
},
_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) {
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[' ', ':', ','];
},
_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;
var trim = this._trim(text);
this._datasource.didChange(trim);
var node = this._getNode();
node.style.left = x + 'px';
node.style.top = y + 'px';
JX.DOM.show(node);
var echo = this._getEchoNode();
var hint = trim;
if (!hint.length) {
hint = this._getSpec().hintText;
}
JX.DOM.setContent(echo, hint);
},
_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 comma),
// then presses tab or return to pick the completion, don't destroy the
// trailing character.
var suffixes = this._getSuffixes();
var value = this._value;
for (var ii = 0; ii < suffixes.length; ii++) {
var last = value.substring(value.length - suffixes[ii].length);
if (last == suffixes[ii]) {
ref += suffixes[ii];
break;
}
}
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;
}
}
});