odoo.define('mail.composer.MentionManager', function (require) { "use strict"; var core = require('web.core'); var dom = require('web.dom'); var session = require('web.session'); var Widget = require('web.Widget'); var QWeb = core.qweb; var NON_BREAKING_SPACE = '\u00a0'; // The MentionManager allows the Composer to register listeners. For each // listener, it detects if the user is currently typing a mention (starting by a // given delimiter). If so, if fetches mention suggestions and renders them. On // suggestion clicked, it updates the selection for the corresponding listener. var MentionManager = Widget.extend({ className: 'dropup o_composer_mention_dropdown', events: { 'click .o_mention_proposition': '_onClickMentionItem', 'mouseover .o_mention_proposition': '_onHoverMentionProposition', }, init: function (parent) { this._super.apply(this, arguments); this._composer = parent; this._open = false; this._listeners = []; this.set('mention_suggestions', []); this.on('change:mention_suggestions', this, this._renderSuggestions); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Returns true if the mention suggestions dropdown is open, false otherwise * * @returns {boolean} */ isOpen: function () { return this._open; }, /** * Returns the mentions of the given listener that haven't been erased from * the composer's input * * @returns {Array} */ getListenerSelection: function (delimiter) { var listener = _.findWhere(this._listeners, { delimiter: delimiter }); if (listener) { var escapedVal = _.escape(this._composer.$input.val()); var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | |$)', 'g')); return this._validateSelection(listener.selection, inputMentions); } return []; }, /** * @returns {Object} containing listener selections, grouped by delimiter * as key of the object. */ getListenerSelections: function () { var selections = {}; _.each(this._listeners, function (listener) { selections[listener.delimiter] = listener.selection; }); return selections; }, /** * Detects if the user is currently typing a mention word */ detectDelimiter: function () { var self = this; var textVal = this._composer.$input.val(); var cursorPosition = this._getComposerSelectionPositions().start; var leftString = textVal.substring(0, cursorPosition); function validateKeyword(delimiter, beginningOnly) { // use position before delimiter because there should be whitespaces // or line feed/carriage return before the delimiter var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; if (beginningOnly && beforeDelimiterPosition > 0) { return false; } var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition); // regex string start with delimiter or whitespace then delimiter var pattern = "^"+delimiter+"|^\\s"+delimiter; var regexStart = new RegExp(pattern, 'g'); // trim any left whitespaces or the left line feed/ carriage return // at the beginning of the string searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); if (regexStart.test(searchStr) && searchStr.length) { searchStr = searchStr.replace(pattern, ''); return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ? searchStr.replace(delimiter, '') : false; } return false; } this._activeListener = undefined; for (var i = 0; i < this._listeners.length; i++) { var listener = this._listeners[i]; this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly); if (this._mentionWord !== false) { this._activeListener = listener; break; } } if (this._activeListener) { var mentionWord = this._mentionWord; Promise.resolve(this._activeListener.fetchCallback(mentionWord)) .then(function (suggestions) { if (mentionWord === self._mentionWord) { // update suggestions only if mentionWord didn't change // in the meantime self.set('mention_suggestions', suggestions); } }); } else { this.set('mention_suggestions', []); // close the dropdown } }, /** * Replaces mentions appearing in the string 's' by html links with proper * redirection * * @param {string} s * @returns {string} */ generateLinks: function (s) { var self = this; var baseHREF = session.url('/web'); var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'"; var mentionLink = "" + "%s%s" + ""; _.each(this._listeners, function (listener) { if (!listener.generateLinks) { return; } var selection = listener.selection; if (selection.length) { var matches = self._getMatch(s, listener); var substrings = []; var startIndex = 0; for (var i = 0; i < matches.length; i++) { var match = matches[i]; var endIndex = match.index + match[0].length; var selectionID = self._getSelectionID(match, selection); // put back white spaces instead of non-breaking spaces in // mention's name var matchName = match[0].substring(1) .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' '); var href = baseHREF + _.str.sprintf("#model=%s&id=%s", listener.model, selectionID); var processedText = _.str.sprintf(mentionLink, href, listener.redirectClassname, selectionID, listener.model, listener.delimiter, matchName); substrings.push(s.substring(startIndex, match.index)); substrings.push(processedText); startIndex = endIndex; } substrings.push(s.substring(startIndex, s.length)); s = substrings.join(''); } }); return s; }, /** * @param {integer} keycode */ propositionNavigation: function (keycode) { var $active = this.$('.o_mention_proposition.active'); if (keycode === $.ui.keyCode.ENTER) { // selecting proposition $active.click(); } else { // navigation in propositions var $to; if (keycode === $.ui.keyCode.DOWN) { $to = $active.nextAll('.o_mention_proposition').first(); } else if (keycode === $.ui.keyCode.UP) { $to = $active.prevAll('.o_mention_proposition').first(); } else if (keycode === $.ui.keyCode.TAB) { $to = $active.nextAll('.o_mention_proposition').first(); if (!$to.length) { $to = $active.prevAll('.o_mention_proposition').last(); } } if ($to && $to.length) { $active.removeClass('active'); $to.addClass('active'); } } }, /** * Registers a new listener, described by an object containing the following * keys * * @param {Object} listener * @param {boolean} [listener.beginningOnly] true to enable autocomplete * only at first position of input * @param {char} [listener.delimiter] the mention delimiter * @param {function} [listener.fetchCallback] the callback to fetch mention * suggestions * @param {boolean} [listener.generateLinks] true to wrap mentions in * links * @param {string} [listener.model=''] (optional) the model used for * redirection * @param {string} [listener.redirectClassname=''] (optional) the classname * of the wrapping the mention * @param {Array} [listener.selection=[]] (optional) initial mentions for * each listener * @param {string} [listener.suggestionTemplate] the template of * suggestions' dropdown */ register: function (listener) { this._listeners.push(_.defaults(listener, { model: '', redirectClassname: '', selection: [], })); }, resetSuggestions: function () { this.set('mention_suggestions', []); }, resetSelections: function () { _.each(this._listeners, function (listener) { listener.selection = []; }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Returns the matches (as RexExp.exec does) for the mention in the input * text * * @private * @param {string} inputText: the text to search matches * @param {Object} listener: the listener for which we want to find a match * @returns {Object[]} matches in the same format as RexExp.exec() */ _getMatch: function (inputText, listener) { // create the regex of all mention's names var names = _.pluck(listener.selection, 'name'); var escapedNames = _.map(names, function (str) { return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= | |$)"; }); var regexStr = escapedNames.join('|'); // extract matches var result = []; if (regexStr.length) { var myRegexp = new RegExp(regexStr, 'g'); var match = myRegexp.exec(inputText); while (match !== null) { result.push(match); match = myRegexp.exec(inputText); } } return result; }, /** * @private * @param {string[]} match * @param {Object} selection * @returns {Object} */ _getSelectionID: function (match, selection) { return _.findWhere(selection, { name: match[0].slice(1) }).id; }, /** * Get cursor position and selection * * @private * @returns a current cursor position */ _getComposerSelectionPositions: function () { return this._composer.getSelectionPositions(); }, /** * @private */ _renderSuggestions: function () { var suggestions = []; if (_.find(this.get('mention_suggestions'), _.isArray)) { // Array of arrays -> Flatten and insert dividers between groups var insertDivider = false; _.each(this.get('mention_suggestions'), function (suggestionGroup) { if (suggestionGroup.length > 0) { if (insertDivider) { suggestions.push({ divider: true }); } suggestions = suggestions.concat(suggestionGroup); insertDivider = true; } }); } else { suggestions = this.get('mention_suggestions'); } if (suggestions.length) { this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, { suggestions: _.map(suggestions, function (suggestion) { // mention manager stores escaped suggestion names return _.extend({}, suggestion, { name: _.unescape(suggestion.name) }); }) })); this.$el .addClass('show') .find('.dropdown-menu') .addClass('show') .css('max-width', this._composer.$input.width()) .find('.o_mention_proposition') .first() .addClass('active'); this._open = true; } else { this.$el.removeClass('show') .find('.dropdown-menu') .removeClass('show'); this.$el.empty(); this._open = false; } }, /* * Set cursor position * * @private * @param {integer} pos */ _setCursorPosition: function (pos) { this._composer.$input.each(function (index, elem) { dom.setSelectionRange(elem, { start: pos, end: pos }); }); }, /** * @private * @param {Object} selection * @param {Array} inputMentions * @returns {Array} */ _validateSelection: function (selection, inputMentions) { var validatedSelection = []; _.each(inputMentions, function (mention) { var validatedMention = _.findWhere(selection, { name: mention.slice(1) }); if (validatedMention) { validatedSelection.push(validatedMention); } }); return validatedSelection; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {KeyboardEvent} ev - to get a current selected mention from list * and eventlistener. */ _onClickMentionItem: function (ev) { ev.preventDefault(); var textInput = this._composer.$input.val(); var id = $(ev.currentTarget).data('id'); var suggestions = _.flatten(this.get('mention_suggestions')); var selectedSuggestion = _.clone(_.find(suggestions, function (s) { return s.id === id; })); var substitution = selectedSuggestion.substitution; if (!substitution) { // no substitution string given, so use the mention name instead // replace white spaces with non-breaking spaces to facilitate // mentions detection in text. // mention manager stores escaped suggestion names selectedSuggestion.name = _.unescape(selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE)); substitution = this._activeListener.delimiter + selectedSuggestion.name; } var getMentionIndex = function (matches, cursorPosition) { for (var i = 0; i < matches.length; i++) { if (cursorPosition <= matches[i].index) { return i; } } return i; }; // add the selected suggestion to the list if (this._activeListener.selection.length) { // get mention matches (ordered by index in the text) var matches = this._getMatch(textInput, this._activeListener); var index = getMentionIndex(matches, this._getComposerSelectionPositions().start); this._activeListener.selection.splice(index, 0, selectedSuggestion); } else { this._activeListener.selection.push(selectedSuggestion); } // update input text, and reset dropdown var cursorPosition = this._getComposerSelectionPositions().start; var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); var textRight = textInput.substring(cursorPosition, textInput.length); var textInputNew = textLeft + substitution + ' ' + textRight; this._composer.$input.val(textInputNew); this._setCursorPosition(textLeft.length+substitution.length+2); this.set('mention_suggestions', []); this._composer.focus('body'); // to trigger autoresize // suggestion after inserting will be used with escaped content if (selectedSuggestion.name) { selectedSuggestion.name = _.escape(selectedSuggestion.name); } }, /** * @private * @param {MouseEvent} ev */ _onHoverMentionProposition: function (ev) { var $elem = $(ev.currentTarget); this.$('.o_mention_proposition').removeClass('active'); $elem.addClass('active'); }, }); return MentionManager; });