/*
* Copyright 2024 Anyware Services
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Field that displays a textarea in which it is possible to mention a user
*/
Ext.define('Ametys.form.field.TextAreaWithMentions', {
extend: 'Ametys.form.AbstractFieldsWrapper',
/** @cfg {String} triggerChar=@ The char that starts mentions */
triggerChar: '@',
/** @cfg {Number} minChars=2 The number of chars to type before starting search */
minChars: 2,
/** @cfg {Boolean} allowRepeat=true Can one user be mentioned several times */
allowRepeat: true,
/** @cfg {Boolean} showAvatars=true Should display user avatars in mentions */
showAvatars: true,
/** @cfg {Number} maxResults=10 The max number of results displayed in the combobox */
maxResults: 10,
/** @cfg {Function} renderMentionedUserFn=null The function to display mentioned user */
renderMentionedUserFn: null,
/** @cfg {Object} textareaConfig The configuration object for the textarea. Note that many configuration can be set directly here and will we broadcasted to underlying field (allowBlank...) */
/** @cfg {Boolean} allowBlank This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-allowBlank} */
/** @cfg {Boolean} blankText This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-blankText} */
/** @cfg {String} emptyText This property is copied to underlying textarea. See {@link Ext.form.field.TextArea#cfg-emptyText} */
/** @cfg {String} invalidText This property is copied to underlying text fields. See {@link Ext.form.field.Text#cfg-invalidText} */
/** @cfg {RegExp} maskRe This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-maskRe} */
/** @cfg {Number} maxLength This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-maxLength} */
/** @cfg {String} maxLengthText This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-maxLengthText} */
/** @cfg {Number} minLength This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-minLength} */
/** @cfg {String} minLengthText This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-minLengthText} */
/** @cfg {RegExp} regex This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-regex} */
/** @cfg {String} regexText This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-regexText} */
/** @cfg {Boolean} selectOnFocus This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-selectOnFocus} */
/** @cfg {Number} size This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-size} */
/** @cfg {RegExp} stripCharsRe This property is copied to underlying text fields. See {@link Ext.form.field.TextArea#cfg-stripCharsRe} */
displayField: 'fullname',
valueField: 'value',
alias: ['widget.ametystextarea.withmentions'],
layout: 'fit',
_currentDataQuery: '',
constructor: function(config)
{
config.cls = Ext.Array.from(config.cls);
config.cls.push('a-textarea-with-mentions');
this.renderMentionedUserFn = config.renderMentionedUserFn || this._renderMentionedUser;
this._overlay = Ext.create("Ext.Component", {
cls: 'a-textarea-with-mentions-overlay'
});
this._overlayBg = Ext.create("Ext.Component", {
cls: 'a-textarea-with-mentions-overlay-bg'
});
var textareaConfig = this.textareaConfig || {};
textareaConfig.cls = 'a-textarea-with-mentions-textarea';
textareaConfig.enableKeyEvents = true;
textareaConfig.listeners = {
'keydown': this._onKeydown,
'keypress': this._onKeypress,
'click': this._onClick,
'change': this._onChange,
'scroll': {
fn: this._onTextareaScroll,
element: 'inputEl'
},
scope: this
};
var propertiesToCopy = this._getConfigPropertiesToCopy();
this._copyPropIfDefined(textareaConfig, propertiesToCopy, config);
this._textarea = Ext.create("Ext.form.field.TextArea", textareaConfig);
this._combo = Ext.create("Ext.form.field.ComboBox", {
valueField: 'value',
displayField: 'displayName',
cls: 'a-textarea-with-mentions-combo',
minChars: 0,
store: this.getStore(),
listeners: {
'focus': this._onComboFocus,
'select': this._onComboSelect,
scope: this
}
});
this._combo.onFocusLeave = function() {
// nothing
};
this.reset();
config.items = [ this._textarea, this._combo, this._overlay, this._overlayBg ];
this.callParent(arguments);
this.on('blur', this._onBlur, this);
},
/**
* @protected
* Retrieves the name of the configuration properties to copy to the underlying field
* @return {String[]} The name of the properties
*/
_getConfigPropertiesToCopy: function()
{
return ['allowBlank', 'blankText', 'emptyText', 'invalidText', 'maskRe', 'maxLength', 'maxLengthText', 'minLength', 'minLengthText', 'regex', 'regexText', 'selectOnFocus', 'size', 'stripCharsRe', 'fieldStyle'];
},
_onComboFocus: function()
{
this._textarea.focus();
},
_onComboSelect: function(combo, record)
{
if (record)
{
this._addMention(record);
combo.reset();
}
},
_onTextareaScroll: function()
{
if (this._overlay.el)
{
let scrollTop = this._textarea.inputEl.getScrollTop();
this._overlay.el.setScrollTop(scrollTop);
this._overlayBg.el.setScrollTop(scrollTop);
}
},
getStore: function()
{
return Ext.create('Ext.data.Store', {
model: 'Ametys.form.field.TextAreaWithMentions.Users',
proxy: {
type: 'ametys',
role: "org.ametys.plugins.core.user.UserDAO",
methodName: "searchUsersByContexts",
methodArguments: ['contexts', 'limit', 'offset', 'searchCriteria', 'limitedToStoredUserData'],
cancelOutdatedRequest: true,
reader: {
type: 'json',
rootProperty: 'users'
}
},
pageSize: this.maxResult,
sortOnLoad: true,
sorters: [{property: 'displayName', direction:'ASC'}],
listeners: {
beforeload: {fn: this._onStoreBeforeLoad, scope: this}
}
});
},
/**
* Set the request parameters before loading the store.
* @param {Ext.data.Store} store The store.
* @param {Ext.data.operation.Operation} operation The Ext.data.Operation object that will be passed to the Proxy to load the Store.
* @protected
*/
_onStoreBeforeLoad: function(store, operation)
{
var proxy = operation.getProxy();
proxy.setExtraParam('limit', this.maxResult);
proxy.setExtraParam('offset', 0);
proxy.setExtraParam('searchCriteria', operation.getParams().query);
},
_updateValues: function()
{
function regexpEncode(str)
{
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
function encode(str)
{
return str.replaceAll('&', '&').replaceAll('<', '<')
}
let syntaxMessage = this._textarea.getValue();
for (let mention of this._mentionsCollection)
{
let textSyntax = "@(" + mention.get(this.valueField) + ")";
syntaxMessage = syntaxMessage.replace(new RegExp(regexpEncode(this.triggerChar + mention.get(this.displayField)), 'g'), textSyntax);
}
let mentionText = encode(syntaxMessage); //Encode the syntaxMessage
for (let mention of this._mentionsCollection)
{
let textSyntax = "@(" + mention.get(this.valueField) + ")";
var textHighlight = this.renderMentionedUserFn(mention, this.displayField, this.triggerChar);
mentionText = mentionText.replace(new RegExp(regexpEncode(textSyntax), 'g'), textHighlight);
}
mentionText = mentionText.replace(/\n/g, '<br />'); //Replace the escape character for <br />
mentionText = mentionText.replace(/ {2}/g, ' '); //Replace the 2 preceding token to
this._messageText = syntaxMessage;
if (/<br\s*\/>\s*$/.test(mentionText))
{
mentionText += " ";
}
this._overlay.update("<div>" + mentionText + "</div>");
this._overlayBg.update("<div>" + mentionText + "</div>");
this._onTextareaScroll();
},
_renderMentionedUser: function(mention, displayField, prefixChar)
{
let currentUser = Ametys.getAppParameter('user');
let isCurrentUser = currentUser.login === mention.get("login") && currentUser.population === mention.get("populationId");
let highlightCls = isCurrentUser ? "current-user" : "";
return '<strong class="' + highlightCls + '"><span>' + prefixChar + mention.get(displayField) + '</span></strong>';
},
_updateMentionsCollection: function()
{
let inputText = this._textarea.getValue();
this._mentionsCollection = this._mentionsCollection.filter(m => inputText.indexOf(m.get(this.displayField)) != -1);
},
_addMention: function(record)
{
let currentMessage = this._textarea.getValue();
let textareaInputEl = this._textarea.inputEl.dom;
let caretStart = textareaInputEl.selectionStart;
let shortestDistance = false;
let bestLastIndex = false;
// Using a regex to figure out positions
let regex = new RegExp("\\" + this.triggerChar + this._currentDataQuery, "gi");
while (regex.exec(currentMessage))
{
if (shortestDistance === false || Math.abs(regex.lastIndex - caretStart) < shortestDistance)
{
shortestDistance = Math.abs(regex.lastIndex - caretStart);
bestLastIndex = regex.lastIndex;
}
}
let startCaretPosition = bestLastIndex - this._currentDataQuery.length - 1; //Set the start caret position (right before the @)
let currentCaretPosition = bestLastIndex; //Set the current caret position (right after the end of the "mention")
let start = currentMessage.substr(0, startCaretPosition);
let end = currentMessage.substr(currentCaretPosition, currentMessage.length);
let startEndIndex = (start + record.get(this.displayField)).length + 1;
// See if there's the same mention in the list
if (this._mentionsCollection.filter(e => e.get(this.valueField) == record.get(this.valueField)).length == 0)
{
this._mentionsCollection.push(record);//Add the mention to mentionsColletions
}
// Cleaning before inserting the value, otherwise auto-complete would be triggered with "old" inputbuffer
this._resetBuffer();
this._currentDataQuery = '';
// Mentions and syntax message
let updatedMessageText = start + this.triggerChar + record.get(this.displayField) + ' ' + end;
this._textarea.setValue(updatedMessageText);
this._updateValues();
// Set correct focus and selection
this._textarea.focus();
this._textarea.selectText(startEndIndex, startEndIndex);
// textareaInputEl.setSelectionRange(startEndIndex, startEndIndex);
},
getValue: function()
{
return this._messageText;
},
setValue: function(value)
{
let text = value.replaceAll("<br/>", "\n");
let users = this._parseMentionnedUsers(value);
if (users.length)
{
Ametys.data.ServerComm.callMethod({
role: "org.ametys.plugins.core.user.UserDAO",
methodName: "getUsersByUserIdentitiesAndContexts",
parameters: [users, null, false],
callback: {
handler: this._getUsersCB,
arguments: {
value: text
},
scope: this
},
waitMessage: {target: this},
errorMessage: true
});
}
else
{
this._textarea.setValue(text);
this._updateValues();
}
},
_getUsersCB: function(users, params)
{
let text = params.value;
for (let user of users)
{
let record = new Ametys.form.field.TextAreaWithMentions.Users(user);
let textToReplace = "@(" + record.get(this.valueField) + ")";
let textSyntax = record.get(this.displayField);
text = text.replaceAll(textToReplace, textSyntax);
this._mentionsCollection.push(record);
}
this._textarea.setValue(text);
this._updateValues();
},
_parseMentionnedUsers: function(value)
{
let users = [];
const regex = /@\(([^()]+)\)/g;
let match = regex.exec(value);
while (match !== null)
{
users.push(match[1]);
match = regex.exec(value);
}
return users;
},
_resetBuffer: function()
{
this._inputBuffer = [];
},
_onKeydown: function(textarea, event)
{
// This also matches HOME/END on OSX which is CMD+LEFT, CMD+RIGHT
if (event.getKey() === event.LEFT || event.getKey() === event.RIGHT || event.getKey() === event.HOME || event.getKey() === event.END || event.getKey() === event.ESC)
{
// Defer execution to ensure carat pos has changed after HOME/END keys then call the resetBuffer function
Ext.defer(this._resetBuffer, 1, this);
this._combo.collapse();
return;
}
if (event.getKey() === event.SPACE)
{
this._resetBuffer();
return;
}
//If the key pressed was the backspace
if (event.getKey() === event.BACKSPACE)
{
this._inputBuffer = this._inputBuffer.slice(0, -1 + this._inputBuffer.length);
return;
}
if (!this._combo.isExpanded)
{
return;
}
let picked = this._combo.getPicker();
switch (event.getKey()) {
case event.UP: //If the key pressed was UP or DOWN
if (picked.highlightedItem && picked.highlightedItem.previousSibling)
{
picked.highlightItem(picked.highlightedItem.previousSibling);
}
else
{
picked.highlightItem(picked.listEl.last().dom);
}
event.preventDefault();
return false;
case event.DOWN:
if (picked.highlightedItem && picked.highlightedItem.nextSibling)
{
picked.highlightItem(picked.highlightedItem.nextSibling);
}
else
{
picked.highlightItem(picked.listEl.first().dom);
}
event.preventDefault();
return false;
case event.RETURN: //If the key pressed was RETURN or TAB
case event.TAB:
let r = picked.getRecord(picked.highlightedItem);
this._combo.select(r, true);
this._combo.fireEvent('select', this._combo, r);
event.preventDefault();
return false;
}
return true;
},
reset: function()
{
this._textarea.reset();
this._combo.reset();
this._mentionsCollection = [];
this._resetBuffer();
this._currentDataQuery = '';
this._updateValues();
},
_onKeypress: function(textarea, event)
{
this._inputBuffer.push(event.getChar()); //Push the value pressed into inputBuffer
},
_onClick: function()
{
this._resetBuffer();
},
_onBlur: function()
{
this._combo.collapse();
},
_onChange: function()
{
this._updateValues();
this._updateMentionsCollection();
let triggerCharIndex = this._inputBuffer.lastIndexOf(this.triggerChar); //Returns the last match of the triggerChar in the inputBuffer
if (triggerCharIndex > -1)
{
//If the triggerChar is present in the inputBuffer array
this._currentDataQuery = this._inputBuffer.slice(triggerCharIndex + 1).join(''); //Gets the currentDataQuery
this._currentDataQuery = this._currentDataQuery.replace(/\s+$/,""); //Deletes the whitespaces
if (this._currentDataQuery.length >= this.minChars)
{
this._combo.doQuery(this._currentDataQuery);
}
}
else
{
this._combo.collapse();
}
}
});
Ext.define("Ametys.form.field.TextAreaWithMentions.Users", {
extend: 'Ext.data.Model',
idProperty: 'value',
fields: [
{name: 'firstname', type: 'string'},
{name: 'lastname', type: 'string'},
{name: 'login', type: 'string'},
{name: 'populationId', type: 'string'},
{name: 'populationLabel', type: 'string'},
{
name: 'value',
type: 'string',
calculate: function(data)
{
return data.login + '#' + data.populationId;
}
},
{name: 'fullname', type: 'string'},
{name: 'sortablename', type: 'string'},
{
name: 'displayName',
type: 'string',
calculate: function (data)
{
return Ametys.helper.Users.renderUser(data.login, data.populationLabel, data.sortablename);
}
}
]
});