comindware.core.ui
Version:
Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.
254 lines (225 loc) • 9.14 kB
text/typescript
import template from './templates/mentionEditor.hbs';
import dropdown from 'dropdown';
import { keyCode } from 'utils';
import BaseEditorView from './base/BaseEditorView';
import UserService from 'services/UserService';
import TextAreaEditorView from './TextAreaEditorView';
import LocalizationService from '../../services/LocalizationService';
import formRepository from '../formRepository';
import MembersListView from './impl/members/views/MembersListView';
import MembersCollection from './impl/members/collections/MembersCollection';
const defaultOptions = {
editorOptions: undefined
};
/**
* @name MentionEditorView
* @memberof module:core.form.editors
* @class Текстовый редактор с возможностью упоминания пользователей (mentions). Поддерживаемый тип данных: <code>String</code>
* (простой многострочный текст). Например, <code>'Hello, @alex!'</code>. Список доступных пользователей
* берется из <code>core.services.CacheService</code>.
* @extends module:core.form.editors.base.BaseEditorView
* @param {Object} options Options object. All the properties of {@link module:core.form.editors.base.BaseEditorView BaseEditorView} class are also supported.
* @param {Number} [options.editorOptions=Object] Опции для используемого {@link module:core.form.editors.TextAreaEditorView TextAreaEditorView}.
* */
export default (formRepository.editors.Mention = BaseEditorView.extend({
initialize(options = {}) {
this.__applyOptions(options, defaultOptions);
this.__createViewModel();
},
__createViewModel() {
this.viewModel = new Backbone.Model();
const membersCollection = new MembersCollection();
membersCollection.reset(UserService.listUsers());
this.viewModel.set('availableMembers', membersCollection);
this.viewModel.set(
'membersByUserName',
membersCollection.reduce((memo, value) => {
memo[value.get('userName')] = value;
return memo;
}, {})
);
},
template: Handlebars.compile(template),
regions: {
dropdownRegion: '.js-dropdown-region'
},
events: {
'keydown:captured': '__handleKeydown'
},
onRender() {
if (this.dropdownView) {
this.stopListening(this.dropdownView);
}
this.dropdownView = dropdown.factory.createDropdown({
buttonView: TextAreaEditorView,
buttonViewOptions: _.extend({}, this.options.editorOptions || {}, {
model: this.model,
readonly: this.getReadonly(),
enabled: this.getEnabled(),
key: this.key,
autocommit: this.options.autocommit,
emptyPlaceholder: LocalizationService.get('CORE.FORM.EDITORS.MENTIONS.PLACEHOLDER')
}),
panelView: MembersListView,
panelViewOptions: {
collection: this.viewModel.get('availableMembers')
},
autoOpen: false,
renderAfterClose: false
});
this.showChildView('dropdownRegion', this.dropdownView);
this.listenTo(this.dropdownView, 'change', this.__onTextChange);
this.listenTo(this.dropdownView, 'focus', this.__onFocus);
this.listenTo(this.dropdownView, 'blur', this.__onBlur);
this.listenTo(this.dropdownView, 'input', this.__onInput);
this.listenTo(this.dropdownView, 'caretChange', this.__onCaretChange);
this.listenTo(this.dropdownView, 'panel:member:select', this.__onMemberSelect);
// We discarded it during render phase, so we do it now.
this.setPermissions(this.enabled, this.readonly);
this.setValue(this.value);
},
__handleKeydown(e) {
switch (e.keyCode) {
case keyCode.UP:
if (!this.dropdownView.isOpen) {
return true;
}
this.__sendPanelCommand('up');
this.__updateMentionInText();
return false;
case keyCode.DOWN:
if (!this.dropdownView.isOpen) {
return true;
}
this.__sendPanelCommand('down');
this.__updateMentionInText();
return false;
case keyCode.ENTER:
case keyCode.NUMPAD_ENTER:
if (!this.dropdownView.isOpen) {
return true;
}
this.__updateMentionInText();
this.dropdownView.close();
return false;
case keyCode.ESCAPE:
this.dropdownView.close();
return false;
default:
break;
}
},
__value(value): void {
this.setValue(value);
this.__triggerChange();
},
__onTextChange(): void {
this.value = this.dropdownView.getValue();
this.__triggerChange();
},
__onFocus(): void {
this.trigger('focus', this);
},
__onBlur(): void {
this.trigger('blur', this);
},
__onInput(text: string, caret: { start: number, end: number }): void {
// 1. Open dropdown when: @ is immediately before caret, @ is at start or prepended by whitespace
// 2. Maintain dropdown open (and filter the list) when: text between caret and @ matches username pattern [a-zA-Z0-9_\.]
// 3. Hide dropdown when: username text doesn't match
// 4. track caret change and hide dropdown if resulting username patters doesn't match
// 5. hide dropdown on blur
const leftFragment = text.substring(0, caret.end);
const regex = /(?:\s|^)@([a-z0-9_\.]*)$/i;
const match = leftFragment.match(regex);
if (match) {
const userName = match[1];
this.dropdownView.open();
const collection = this.viewModel.get('availableMembers');
collection.applyTextFilter(userName);
if (collection.length === 0) {
this.dropdownView.close();
}
this.mentionState = {
start: caret.end - userName.length,
end: caret.end,
text
};
} else {
this.dropdownView.close();
}
},
__onCaretChange(text, caret) {
if (this.dropdownView.isOpen) {
this.__onInput(text, caret);
}
},
__onMemberSelect(): void {
this.__updateMentionInText();
this.dropdownView.close();
},
__updateMentionInText(): void {
const selectedMember = this.viewModel.get('availableMembers').selected;
if (!selectedMember) {
return;
}
const editor = this.dropdownView;
const text = this.mentionState.text;
let mention = selectedMember.get('userName') || '';
if (mention && !text.substring(this.mentionState.end).match(/^\s/)) {
mention += ' ';
}
const updatedText = text.substring(0, this.mentionState.start) + mention + text.substring(this.mentionState.end);
editor.setValue(updatedText);
editor.setCaretPos(this.mentionState.start + mention.length);
this.value = updatedText;
this.__triggerChange();
},
/**
* Получить список пользователей, упомянутых в тексте.
* @return {String[]} Список строковых идентификаторов пользователей, упомянутых в тексте.
* */
getMentions(): Array<any> {
const text = this.getValue();
const regex = /(?:\s|^)@([a-z0-9_\.]+)/gi;
const members = this.viewModel.get('membersByUserName');
const result = [];
while (true) {
const matches = regex.exec(text);
if (!matches) {
break;
}
const userName = matches[1];
const member = members[userName];
if (member) {
result.push(member.id);
}
}
return _.uniq(result);
},
setValue(value) {
if (this.dropdownView) {
this.dropdownView.setValue(value);
this.value = this.dropdownView.getValue();
} else {
this.value = value;
}
},
__sendPanelCommand(command, options) {
if (this.dropdownView.isOpen) {
this.dropdownView.panelView.handleCommand(command, options);
}
},
__setEnabled(enabled: boolean): void {
BaseEditorView.prototype.__setEnabled.call(this, enabled);
if (this.dropdownView) {
this.dropdownView.setEnabled(enabled);
}
},
__setReadonly(readonly: boolean): void {
BaseEditorView.prototype.__setReadonly.call(this, readonly);
if (this.dropdownView) {
this.dropdownView.setReadonly(readonly);
}
}
}));