1
0
mirror of https://github.com/flarum/core.git synced 2025-07-25 10:41:24 +02:00
Files
php-flarum/extensions/mentions/js/src/forum/addComposerAutocomplete.js
Alexander Skvortsov ea56f39a53 Mithril 2 Update (#52)
Update for Mithril 2

- Please note that PostQuoteButton and AutocompleteDropdown are not Fragments, not Components. Accordingly, they have been moved to the components folder.

Co-authored-by: Matthew Kilgore <tankerkiller125@gmail.com>
Co-authored-by: Franz Liedke <franz@develophp.org>
2020-09-23 22:53:46 -04:00

214 lines
8.2 KiB
JavaScript

import getCaretCoordinates from 'textarea-caret';
import { extend } from 'flarum/extend';
import TextEditor from 'flarum/components/TextEditor';
import TextEditorButton from 'flarum/components/TextEditorButton';
import ReplyComposer from 'flarum/components/ReplyComposer';
import EditPostComposer from 'flarum/components/EditPostComposer';
import avatar from 'flarum/helpers/avatar';
import usernameHelper from 'flarum/helpers/username';
import highlight from 'flarum/helpers/highlight';
import KeyboardNavigatable from 'flarum/utils/KeyboardNavigatable';
import { truncate } from 'flarum/utils/string';
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
export default function addComposerAutocomplete() {
extend(TextEditor.prototype, 'oncreate', function () {
const $container = $('<div class="ComposerBody-mentionsDropdownContainer"></div>');
const dropdown = new AutocompleteDropdown();
const $textarea = this.$('textarea').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
const searched = [];
let mentionStart;
let typed;
let searchTimeout;
// We store users returned from an API here to preserve order in which they are returned
// This prevents the user list jumping around while users are returned.
// We also use a hashset for user IDs to provide O(1) lookup for the users already in the list.
const returnedUsers = Array.from(app.store.all('users'));
const returnedUserIds = new Set(returnedUsers.map(u => u.id()));
const applySuggestion = (replacement) => {
app.composer.editor.replaceBeforeCursor(mentionStart - 1, replacement + ' ');
dropdown.hide();
};
this.navigator = new KeyboardNavigatable();
this.navigator
.when(() => dropdown.active)
.onUp(() => dropdown.navigate(-1))
.onDown(() => dropdown.navigate(1))
.onSelect(dropdown.complete.bind(dropdown))
.onCancel(dropdown.hide.bind(dropdown))
.bindTo($textarea);
$textarea
.after($container)
.on('click keyup input', function(e) {
// Up, down, enter, tab, escape, left, right.
if ([9, 13, 27, 40, 38, 37, 39].indexOf(e.which) !== -1) return;
const cursor = this.selectionStart;
if (this.selectionEnd - cursor > 0) return;
// Search backwards from the cursor for an '@' symbol. If we find one,
// we will want to show the autocomplete dropdown!
const value = this.value;
mentionStart = 0;
for (let i = cursor - 1; i >= cursor - 30; i--) {
const character = value.substr(i, 1);
if (character === '@') {
mentionStart = i + 1;
break;
}
}
dropdown.hide();
dropdown.active = false;
if (mentionStart) {
typed = value.substring(mentionStart, cursor).toLowerCase();
const makeSuggestion = function(user, replacement, content, className = '') {
const username = usernameHelper(user);
if (typed) {
username.children = [highlight(username.text, typed)];
delete username.text;
}
return (
<button className={'PostPreview ' + className}
onclick={() => applySuggestion(replacement)}
onmouseenter={function() {
dropdown.setIndex($(this).parent().index());
}}>
<span className="PostPreview-content">
{avatar(user)}
{username} {' '}
{content}
</span>
</button>
);
};
const userMatches = function(user) {
const names = [
user.username(),
user.displayName()
];
return names.some(value => value.toLowerCase().substr(0, typed.length) === typed);
};
const buildSuggestions = () => {
const suggestions = [];
// If the user has started to type a username, then suggest users
// matching that username.
if (typed) {
returnedUsers.forEach(user => {
if (!userMatches(user)) return;
suggestions.push(
makeSuggestion(user, '@' + user.username(), '', 'MentionsDropdown-user')
);
});
}
// If the user is replying to a discussion, or if they are editing a
// post, then we can suggest other posts in the discussion to mention.
// We will add the 5 most recent comments in the discussion which
// match any username characters that have been typed.
if (app.composer.bodyMatches(ReplyComposer) || app.composer.bodyMatches(EditPostComposer)) {
const composerAttrs = app.composer.body.attrs;
const composerPost = composerAttrs.post;
const discussion = (composerPost && composerPost.discussion()) || composerAttrs.discussion;
if (discussion) {
discussion.posts()
.filter(post => post && post.contentType() === 'comment' && (!composerPost || post.number() < composerPost.number()))
.sort((a, b) => b.createdAt() - a.createdAt())
.filter(post => {
const user = post.user();
return user && userMatches(user);
})
.splice(0, 5)
.forEach(post => {
const user = post.user();
suggestions.push(
makeSuggestion(user, '@' + user.username() + '#' + post.id(), [
app.translator.trans('flarum-mentions.forum.composer.reply_to_post_text', {number: post.number()}), ' — ',
truncate(post.contentPlain(), 200)
], 'MentionsDropdown-post')
);
});
}
}
if (suggestions.length) {
dropdown.items = suggestions;
m.render($container[0], dropdown.render());
dropdown.show();
const coordinates = getCaretCoordinates(this, mentionStart);
const width = dropdown.$().outerWidth();
const height = dropdown.$().outerHeight();
const parent = dropdown.$().offsetParent();
let left = coordinates.left;
let top = coordinates.top - this.scrollTop + 15;
if (top + height > parent.height()) {
top = coordinates.top - this.scrollTop - height - 15;
}
if (left + width > parent.width()) {
left = parent.width() - width;
}
dropdown.show(left, top);
} else {
dropdown.active = false;
dropdown.hide();
}
};
dropdown.active = true;
buildSuggestions();
dropdown.setIndex(0);
dropdown.$().scrollTop(0);
clearTimeout(searchTimeout);
// Don't send API calls searching for users until at least 2 characters have been typed.
// This focuses the mention results on users and posts in the discussion.
if (typed.length > 1) {
searchTimeout = setTimeout(function() {
const typedLower = typed.toLowerCase();
if (searched.indexOf(typedLower) === -1) {
app.store.find('users', { filter: { q: typed }, page: { limit: 5 } }).then(results => {
results.forEach(u => {
if (!returnedUserIds.has(u.id())) {
returnedUserIds.add(u.id());
returnedUsers.push(u);
}
})
if (dropdown.active) buildSuggestions();
});
searched.push(typedLower);
}
}, 250);
}
}
});
});
extend(TextEditor.prototype, 'toolbarItems', function(items) {
items.add('mention', (
<TextEditorButton onclick={() => this.props.composer.editor.insertAtCursor('@')} icon="fas fa-at">
{app.translator.trans('flarum-mentions.forum.composer.mention_tooltip')}
</TextEditorButton>
));
});
}