Search for users in autocomplete popup + other tweaks

- Highlight matching parts of usernames
- Fix positioning edge cases
This commit is contained in:
Toby Zerner 2015-05-18 12:24:48 +09:30
parent 547631ac93
commit 547b2b1304
2 changed files with 121 additions and 63 deletions

View File

@ -14,11 +14,31 @@ export default function() {
var composer = this; var composer = this;
var $container = $('<div class="mentions-dropdown-container"></div>'); var $container = $('<div class="mentions-dropdown-container"></div>');
var dropdown = new AutocompleteDropdown({items: []}); var dropdown = new AutocompleteDropdown({items: []});
var typed;
var mentionStart;
var $textarea = this.$('textarea');
var searched = [];
var searchTimeout;
this.$('textarea') var applySuggestion = function(replacement) {
replacement += ' ';
var content = composer.content();
composer.editor.setContent(content.substring(0, mentionStart - 1)+replacement+content.substr($textarea[0].selectionStart));
var index = mentionStart - 1 + replacement.length;
composer.editor.setSelectionRange(index, index);
dropdown.hide();
};
$textarea
.after($container) .after($container)
.on('keydown', dropdown.navigate.bind(dropdown)) .on('keydown', dropdown.navigate.bind(dropdown))
.on('input', function() { .on('click keyup', function(e) {
// Up, down, enter, tab, escape, left, right.
if ([9, 13, 27, 40, 38, 37, 39].indexOf(e.which) !== -1) return;
var cursor = this.selectionStart; var cursor = this.selectionStart;
if (this.selectionEnd - cursor > 0) return; if (this.selectionEnd - cursor > 0) return;
@ -27,7 +47,7 @@ export default function() {
// intervening whitespace. If we find one, we will want to show the // intervening whitespace. If we find one, we will want to show the
// autocomplete dropdown! // autocomplete dropdown!
var value = this.value; var value = this.value;
var mentionStart; mentionStart = 0;
for (var i = cursor - 1; i >= 0; i--) { for (var i = cursor - 1; i >= 0; i--) {
var character = value.substr(i, 1); var character = value.substr(i, 1);
if (/\s/.test(character)) break; if (/\s/.test(character)) break;
@ -40,80 +60,106 @@ export default function() {
dropdown.hide(); dropdown.hide();
if (mentionStart) { if (mentionStart) {
var typed = value.substring(mentionStart, cursor).toLowerCase(); typed = value.substring(mentionStart, cursor).toLowerCase();
var suggestions = [];
var applySuggestion = function(replacement) { var makeSuggestion = function(user, replacement, content, className) {
replacement += ' ';
var content = composer.content();
composer.editor.setContent(content.substring(0, mentionStart - 1)+replacement+content.substr(cursor));
var index = mentionStart + replacement.length;
composer.editor.setSelectionRange(index, index);
dropdown.hide();
};
var makeSuggestion = function(user, replacement, index, content) {
return m('a[href=javascript:;].post-preview', { return m('a[href=javascript:;].post-preview', {
className,
onclick: () => applySuggestion(replacement), onclick: () => applySuggestion(replacement),
onmouseover: () => dropdown.setIndex(index) onmouseenter: function() { dropdown.setIndex($(this).parent().index()); }
}, m('div.post-preview-content', [ }, m('div.post-preview-content', [
avatar(user), avatar(user),
username(user), ' ', (function() {
var vdom = username(user);
if (typed) {
var regexp = new RegExp(typed, 'gi');
vdom.children[0] = m.trust(
$('<div/>').text(vdom.children[0]).html().replace(regexp, '<mark>$&</mark>')
);
}
return vdom;
})(), ' ',
content content
])); ]));
}; };
// If the user is replying to a discussion, or if they are editing a var buildSuggestions = () => {
// post, then we can suggest other posts in the discussion to mention. var suggestions = [];
// We will add the 5 most recent comments in the discussion which
// match any username characters that have been typed. // If the user is replying to a discussion, or if they are editing a
var composerPost = composer.props.post; // post, then we can suggest other posts in the discussion to mention.
var discussion = (composerPost && composerPost.discussion()) || composer.props.discussion; // We will add the 5 most recent comments in the discussion which
if (discussion) { // match any username characters that have been typed.
discussion.posts() var composerPost = composer.props.post;
.filter(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number())) var discussion = (composerPost && composerPost.discussion()) || composer.props.discussion;
.sort((a, b) => b.time() - a.time()) if (discussion) {
.filter(post => { discussion.posts()
var user = post.user(); .filter(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number()))
return user && user.username().toLowerCase().substr(0, typed.length) === typed; .sort((a, b) => b.time() - a.time())
}) .filter(post => {
.splice(0, 5) var user = post.user();
.forEach((post, i) => { return user && user.username().toLowerCase().substr(0, typed.length) === typed;
var user = post.user(); })
.splice(0, 5)
.forEach(post => {
var user = post.user();
suggestions.push(
makeSuggestion(user, '@'+user.username()+'#'+post.number(), [
'Reply to #', post.number(), ' — ',
post.excerpt()
], 'suggestion-post')
);
});
}
// If the user has started to type a username, then suggest users
// matching that username.
if (typed) {
app.store.all('users').forEach(user => {
if (user.username().toLowerCase().substr(0, typed.length) !== typed) return;
suggestions.push( suggestions.push(
makeSuggestion(user, '@'+user.username()+'#'+post.number(), i, [ makeSuggestion(user, '@'+user.username(), '', 'suggestion-user')
'Reply to #', post.number(), ' — ',
post.excerpt()
])
); );
}); });
} }
// If the user has started to type a username, then suggest users if (suggestions.length) {
// matching that username. dropdown.props.items = suggestions;
if (typed) { m.render($container[0], dropdown.view());
app.store.all('users').forEach((user, i) => {
if (user.username().toLowerCase().substr(0, typed.length) !== typed) return;
suggestions.push( dropdown.show();
makeSuggestion(user, '@'+user.username(), i, '@mention') var coordinates = getCaretCoordinates(this, mentionStart);
); var left = coordinates.left;
}); var top = coordinates.top + 15;
} var width = dropdown.$().outerWidth();
var height = dropdown.$().outerHeight();
var parent = dropdown.$().offsetParent();
if (top + height > parent.height()) {
top = coordinates.top - height - 15;
}
if (left + width > parent.width()) {
left = parent.width() - width;
}
dropdown.show(left, top);
}
};
if (suggestions.length) { buildSuggestions();
dropdown.props.items = suggestions;
m.render($container[0], dropdown.view());
var coordinates = getCaretCoordinates(this, mentionStart); dropdown.setIndex(0);
dropdown.show(coordinates.left, coordinates.top + 15); dropdown.$().scrollTop(0);
dropdown.setIndex(0); clearTimeout(searchTimeout);
dropdown.$().scrollTop(0); searchTimeout = setTimeout(function() {
} var typedLower = typed.toLowerCase();
if (searched.indexOf(typedLower) === -1) {
app.store.find('users', {q: typed, page: {limit: 5}}).then(users => {
if (dropdown.active()) buildSuggestions();
});
searched.push(typedLower);
}
}, 250);
} }
}); });
}); });

View File

@ -30,13 +30,25 @@
max-height: 200px; max-height: 200px;
overflow: auto; overflow: auto;
position: absolute; position: absolute;
& mark {
padding: 0;
}
& > li > a:hover {
background: none;
}
} }
.post-preview { .post-preview {
color: @fl-body-muted-color !important; color: @fl-body-muted-color !important;
& .avatar { & .avatar {
.avatar-size(32px); .avatar-size(24px);
margin: 3px 0 3px -45px; margin: 0 0 0 -37px;
.suggestion-post& {
margin-top: 3px;
margin-bottom: 3px;
}
} }
& .username { & .username {
color: @fl-body-color; color: @fl-body-color;
@ -44,7 +56,7 @@
} }
} }
.post-preview-content { .post-preview-content {
padding-left: 45px; padding-left: 37px;
overflow: hidden; overflow: hidden;
line-height: 1.7em; line-height: 1.7em;
} }