mirror of
https://github.com/phpbb/phpbb.git
synced 2025-06-08 04:18:52 +00:00
[ticket/13713] Add mentions code to mentions.js
PHPBB3-13713
This commit is contained in:
parent
6eeb22cdb5
commit
a86d9699a5
4 changed files with 337 additions and 308 deletions
|
@ -8,8 +8,9 @@
|
||||||
// ]]>
|
// ]]>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/tribute.min.js -->
|
|
||||||
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/editor.js -->
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/editor.js -->
|
||||||
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/tribute.min.js -->
|
||||||
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/mentions.js -->
|
||||||
|
|
||||||
<!-- EVENT acp_posting_buttons_before -->
|
<!-- EVENT acp_posting_buttons_before -->
|
||||||
<div id="format-buttons"<!-- IF S_ALLOW_MENTIONS --> data-mention-url="{U_MENTION_URL}" data-mention-names-limit="{S_MENTION_NAMES_LIMIT}" data-topic-id="{S_TOPIC_ID}" data-user-id="{S_USER_ID}"<!-- ENDIF -->>
|
<div id="format-buttons"<!-- IF S_ALLOW_MENTIONS --> data-mention-url="{U_MENTION_URL}" data-mention-names-limit="{S_MENTION_NAMES_LIMIT}" data-topic-id="{S_TOPIC_ID}" data-user-id="{S_USER_ID}"<!-- ENDIF -->>
|
||||||
|
|
|
@ -383,317 +383,39 @@ function getCaretPosition(txtarea) {
|
||||||
|
|
||||||
return caretPos;
|
return caretPos;
|
||||||
}
|
}
|
||||||
/* import Tribute from './jquery.tribute'; */
|
|
||||||
|
/**
|
||||||
|
* Get editor text area element
|
||||||
|
*
|
||||||
|
* @return {HTMLElement|null} Text area element or null if textarea couldn't be found
|
||||||
|
*/
|
||||||
|
function getEditorTextArea() {
|
||||||
|
let doc;
|
||||||
|
|
||||||
|
// find textarea, make sure browser supports necessary functions
|
||||||
|
if (document.forms[form_name]) {
|
||||||
|
doc = document;
|
||||||
|
} else {
|
||||||
|
doc = opener.document;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.forms[form_name]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc.forms[form_name].elements[text_name];
|
||||||
|
}
|
||||||
|
|
||||||
(function($) {
|
(function($) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
|
||||||
* Mentions data returned from ajax requests
|
|
||||||
* @typedef {Object} MentionsData
|
|
||||||
* @property {string} name User/group name
|
|
||||||
* @property {string} id User/group ID
|
|
||||||
* @property {{img: string, group: string}} avatar Avatar data
|
|
||||||
* @property {string} rank User rank or empty string for groups
|
|
||||||
* @property {number} priority Priority of data entry
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mentions class
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
function Mentions() {
|
|
||||||
let $mentionDataContainer = $('[data-mention-url]:first');
|
|
||||||
let mentionURL = $mentionDataContainer.data('mentionUrl');
|
|
||||||
let mentionNamesLimit = $mentionDataContainer.data('mentionNamesLimit');
|
|
||||||
let mentionTopicId = $mentionDataContainer.data('topicId');
|
|
||||||
let mentionUserId = $mentionDataContainer.data('userId');
|
|
||||||
let queryInProgress = null;
|
|
||||||
let cachedNames = [];
|
|
||||||
let cachedAll = [];
|
|
||||||
let cachedSearchKey = 'name';
|
|
||||||
let tribute = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default avatar
|
|
||||||
* @param {string} type Type of avatar; either 'g' for group or user on any other value
|
|
||||||
* @returns {string} Default avatar svg code
|
|
||||||
*/
|
|
||||||
function defaultAvatar(type) {
|
|
||||||
if (type === 'g') {
|
|
||||||
return '<svg class="mention-media-avatar" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><path fill-rule="evenodd" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>';
|
|
||||||
} else {
|
|
||||||
return '<svg class="mention-media-avatar" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><path fill-rule="evenodd" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"/></svg>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get avatar HTML for data and type of avatar
|
|
||||||
*
|
|
||||||
* @param {object} data
|
|
||||||
* @param {string} type
|
|
||||||
* @return {string} Avatar HTML
|
|
||||||
*/
|
|
||||||
function getAvatar(data, type) {
|
|
||||||
const avatarToHtml = (avatarData) => {
|
|
||||||
if (avatarData.html !== '') {
|
|
||||||
return avatarData.html;
|
|
||||||
} else {
|
|
||||||
return '<img class="avatar" src="' + avatarData.src + '" width="' + avatarData.width + '" height="' + avatarData.height + '" alt="' + avatarData.title + '" />';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.html === '' && data.src === '' ? defaultAvatar(type) : "<span class='mention-media-avatar'>" + avatarToHtml(data)+ "</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached keyword for query string
|
|
||||||
* @param {string} query Query string
|
|
||||||
* @returns {?string} Cached keyword if one fits query, else empty string if cached keywords exist, null if cached keywords do not exist
|
|
||||||
*/
|
|
||||||
function getCachedKeyword(query) {
|
|
||||||
if (!cachedNames) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let i;
|
|
||||||
|
|
||||||
for (i = query.length; i > 0; i--) {
|
|
||||||
let startStr = query.substr(0, i);
|
|
||||||
if (cachedNames[startStr]) {
|
|
||||||
return startStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get names matching query
|
|
||||||
* @param {string} query Query string
|
|
||||||
* @param {Object.<number, MentionsData>} items List of {@link MentionsData} items
|
|
||||||
* @param {string} searchKey Key to use for matching items
|
|
||||||
* @returns {Object.<number, MentionsData>} List of {@link MentionsData} items filtered with query and by searchKey
|
|
||||||
*/
|
|
||||||
function getMatchedNames(query, items, searchKey) {
|
|
||||||
let i;
|
|
||||||
let itemsLength;
|
|
||||||
let matchedNames = [];
|
|
||||||
for (i = 0, itemsLength = items.length; i < itemsLength; i++) {
|
|
||||||
let item = items[i];
|
|
||||||
if (isItemMatched(query, item, searchKey)) {
|
|
||||||
matchedNames.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matchedNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return whether item is matched by query
|
|
||||||
*
|
|
||||||
* @param {string} query Search query string
|
|
||||||
* @param {MentionsData} item Mentions data item
|
|
||||||
* @param {string }searchKey Key to use for matching items
|
|
||||||
* @return {boolean} True if items is matched, false otherwise
|
|
||||||
*/
|
|
||||||
function isItemMatched(query, item, searchKey) {
|
|
||||||
return String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase()) === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter items by search query
|
|
||||||
*
|
|
||||||
* @param {string} query Search query string
|
|
||||||
* @param {Object.<number, MentionsData>} items List of {@link MentionsData} items
|
|
||||||
* @return {Object.<number, MentionsData>} List of {@link MentionsData} items filtered with query and by searchKey
|
|
||||||
*/
|
|
||||||
function itemFilter(query, items) {
|
|
||||||
let i;
|
|
||||||
let len;
|
|
||||||
let highestPriorities = {u: 1, g: 1};
|
|
||||||
let _unsorted = {u: {}, g: {}};
|
|
||||||
let _exactMatch = [];
|
|
||||||
let _results = [];
|
|
||||||
|
|
||||||
// Reduce the items array to the relevant ones
|
|
||||||
items = getMatchedNames(query, items, 'name');
|
|
||||||
|
|
||||||
// Group names by their types and calculate priorities
|
|
||||||
for (i = 0, len = items.length; i < len; i++) {
|
|
||||||
let item = items[i];
|
|
||||||
|
|
||||||
// Check for unsupported type - in general, this should never happen
|
|
||||||
if (!_unsorted[item.type]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current user doesn't want to mention themselves with "@" in most cases -
|
|
||||||
// do not waste list space with their own name
|
|
||||||
if (item.type === 'u' && item.id === String(mentionUserId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact matches should not be prioritised - they always come first
|
|
||||||
if (item.name === query) {
|
|
||||||
_exactMatch.push(items[i]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the item hasn't been added yet - add it
|
|
||||||
if (!_unsorted[item.type][item.id]) {
|
|
||||||
_unsorted[item.type][item.id] = item;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority is calculated as the sum of priorities from different sources
|
|
||||||
_unsorted[item.type][item.id].priority += parseFloat(item.priority.toString());
|
|
||||||
|
|
||||||
// Calculate the highest priority - we'll give it to group names
|
|
||||||
highestPriorities[item.type] = Math.max(highestPriorities[item.type], _unsorted[item.type][item.id].priority);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All types of names should come at the same level of importance,
|
|
||||||
// otherwise they will be unlikely to be shown
|
|
||||||
// That's why we normalize priorities and push names to a single results array
|
|
||||||
$.each(['u', 'g'], function(key, type) {
|
|
||||||
if (_unsorted[type]) {
|
|
||||||
$.each(_unsorted[type], function(name, value) {
|
|
||||||
// Normalize priority
|
|
||||||
value.priority /= highestPriorities[type];
|
|
||||||
|
|
||||||
// Add item to all results
|
|
||||||
_results.push(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort names by priorities - higher values come first
|
|
||||||
_results = _results.sort(function(a, b) {
|
|
||||||
return b.priority - a.priority;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Exact match is the most important - should come above anything else
|
|
||||||
$.each(_exactMatch, function(name, value) {
|
|
||||||
_results.unshift(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
return _results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* remoteFilter callback filter function
|
|
||||||
* @param {string} query Query string
|
|
||||||
* @param {function} callback Callback function for filtered items
|
|
||||||
*/
|
|
||||||
function remoteFilter(query, callback) {
|
|
||||||
/*
|
|
||||||
* Do not make a new request until the previous one for the same query is returned
|
|
||||||
* This fixes duplicate server queries e.g. when arrow keys are pressed
|
|
||||||
*/
|
|
||||||
if (queryInProgress === query) {
|
|
||||||
setTimeout(function() {
|
|
||||||
remoteFilter(query, callback);
|
|
||||||
}, 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedKeyword = getCachedKeyword(query),
|
|
||||||
cachedNamesForQuery = (cachedKeyword !== null) ? cachedNames[cachedKeyword] : null;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Use cached values when we can:
|
|
||||||
* 1) There are some names in the cache relevant for the query
|
|
||||||
* (cache for the query with the same first characters contains some data)
|
|
||||||
* 2) We have enough names to display OR
|
|
||||||
* all relevant names have been fetched from the server
|
|
||||||
*/
|
|
||||||
if (cachedNamesForQuery &&
|
|
||||||
(getMatchedNames(query, cachedNamesForQuery, cachedSearchKey).length >= mentionNamesLimit ||
|
|
||||||
cachedAll[cachedKeyword])) {
|
|
||||||
callback(cachedNamesForQuery);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queryInProgress = query;
|
|
||||||
|
|
||||||
let params = {keyword: query, topic_id: mentionTopicId, _referer: location.href};
|
|
||||||
$.getJSON(mentionURL, params, function(data) {
|
|
||||||
cachedNames[query] = data.names;
|
|
||||||
cachedAll[query] = data.all;
|
|
||||||
callback(data.names);
|
|
||||||
}).always(function() {
|
|
||||||
queryInProgress = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate menu item HTML representation. Also ensures that mention-list
|
|
||||||
* class is set for unordered list in mention container
|
|
||||||
*
|
|
||||||
* @param {object} data Item data
|
|
||||||
* @returns {string} HTML representation of menu item
|
|
||||||
*/
|
|
||||||
function menuItemTemplate(data) {
|
|
||||||
const itemData = data;
|
|
||||||
const avatar = getAvatar(itemData.avatar, itemData.type);
|
|
||||||
const rank = (itemData.rank) ? "<span class='mention-rank'>" + itemData.rank + "</span>" : '';
|
|
||||||
const $mentionContainer = $('.' + tribute.current.collection.containerClass);
|
|
||||||
|
|
||||||
if (typeof $mentionContainer !== 'undefined' && $mentionContainer.children('ul').hasClass('mention-list') === false) {
|
|
||||||
$mentionContainer.children('ul').addClass('mention-list');
|
|
||||||
}
|
|
||||||
|
|
||||||
return "<span class='mention-media'>" + avatar + "</span><span class='mention-name'>" + itemData.name + rank + "</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isEnabled = function() {
|
|
||||||
return $mentionDataContainer.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handle = function(textarea) {
|
|
||||||
tribute = new Tribute({
|
|
||||||
trigger: '@',
|
|
||||||
allowSpaces: true,
|
|
||||||
containerClass: 'mention-container',
|
|
||||||
selectClass: 'is-active',
|
|
||||||
itemClass: 'mention-item',
|
|
||||||
menuItemTemplate: menuItemTemplate,
|
|
||||||
selectTemplate: function (item) {
|
|
||||||
return '[mention=' + item.type + ':' + item.id + ']' + item.name + '[/mention]';
|
|
||||||
},
|
|
||||||
menuItemLimit: mentionNamesLimit,
|
|
||||||
values: function (text, cb) {
|
|
||||||
remoteFilter(text, users => cb(users));
|
|
||||||
},
|
|
||||||
lookup: function (element) {
|
|
||||||
return element.hasOwnProperty('name') ? element.name : '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tribute.search.filter = itemFilter;
|
|
||||||
|
|
||||||
tribute.attach($(textarea));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
phpbb.mentions = new Mentions();
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
let doc;
|
const textarea = getEditorTextArea();
|
||||||
let textarea;
|
|
||||||
|
|
||||||
// find textarea, make sure browser supports necessary functions
|
if (typeof textarea === 'undefined') {
|
||||||
if (document.forms[form_name]) {
|
|
||||||
doc = document;
|
|
||||||
} else {
|
|
||||||
doc = opener.document;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!doc.forms[form_name]) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea = doc.forms[form_name].elements[text_name];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow to use tab character when typing code
|
* Allow to use tab character when typing code
|
||||||
* Keep indentation of last line of code when typing code
|
* Keep indentation of last line of code when typing code
|
||||||
|
@ -704,10 +426,6 @@ function getCaretPosition(txtarea) {
|
||||||
phpbb.showDragNDrop(textarea);
|
phpbb.showDragNDrop(textarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phpbb.mentions.isEnabled()) {
|
|
||||||
phpbb.mentions.handle(textarea);
|
|
||||||
}
|
|
||||||
|
|
||||||
$('textarea').on('keydown', function (e) {
|
$('textarea').on('keydown', function (e) {
|
||||||
if (e.which === 13 && (e.metaKey || e.ctrlKey)) {
|
if (e.which === 13 && (e.metaKey || e.ctrlKey)) {
|
||||||
$(this).closest('form').find(':submit').click();
|
$(this).closest('form').find(':submit').click();
|
||||||
|
|
309
phpBB/assets/javascript/mentions.js
Normal file
309
phpBB/assets/javascript/mentions.js
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
/* global phpbb */
|
||||||
|
/* import Tribute from './tribute.min'; */
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mentions data returned from ajax requests
|
||||||
|
* @typedef {Object} MentionsData
|
||||||
|
* @property {string} name User/group name
|
||||||
|
* @property {string} id User/group ID
|
||||||
|
* @property {{img: string, group: string}} avatar Avatar data
|
||||||
|
* @property {string} rank User rank or empty string for groups
|
||||||
|
* @property {number} priority Priority of data entry
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mentions class
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
function Mentions() {
|
||||||
|
const $mentionDataContainer = $('[data-mention-url]:first');
|
||||||
|
const mentionURL = $mentionDataContainer.data('mentionUrl');
|
||||||
|
const mentionNamesLimit = $mentionDataContainer.data('mentionNamesLimit');
|
||||||
|
const mentionTopicId = $mentionDataContainer.data('topicId');
|
||||||
|
const mentionUserId = $mentionDataContainer.data('userId');
|
||||||
|
let queryInProgress = null;
|
||||||
|
const cachedNames = [];
|
||||||
|
const cachedAll = [];
|
||||||
|
const cachedSearchKey = 'name';
|
||||||
|
let tribute = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default avatar
|
||||||
|
* @param {string} type Type of avatar; either 'g' for group or user on any other value
|
||||||
|
* @returns {string} Default avatar svg code
|
||||||
|
*/
|
||||||
|
function defaultAvatar(type) {
|
||||||
|
if (type === 'g') {
|
||||||
|
return '<svg class="mention-media-avatar" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><path fill-rule="evenodd" d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<svg class="mention-media-avatar" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24"><path fill-rule="evenodd" d="M12,19.2C9.5,19.2 7.29,17.92 6,16C6.03,14 10,12.9 12,12.9C14,12.9 17.97,14 18,16C16.71,17.92 14.5,19.2 12,19.2M12,5A3,3 0 0,1 15,8A3,3 0 0,1 12,11A3,3 0 0,1 9,8A3,3 0 0,1 12,5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get avatar HTML for data and type of avatar
|
||||||
|
*
|
||||||
|
* @param {object} data
|
||||||
|
* @param {string} type
|
||||||
|
* @return {string} Avatar HTML
|
||||||
|
*/
|
||||||
|
function getAvatar(data, type) {
|
||||||
|
const avatarToHtml = avatarData => {
|
||||||
|
if (avatarData.html === '') {
|
||||||
|
return '<img class="avatar" src="' + avatarData.src + '" width="' + avatarData.width + '" height="' + avatarData.height + '" alt="' + avatarData.title + '" />';
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatarData.html;
|
||||||
|
};
|
||||||
|
|
||||||
|
return data.html === '' && data.src === '' ? defaultAvatar(type) : '<span class=\'mention-media-avatar\'>' + avatarToHtml(data) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached keyword for query string
|
||||||
|
* @param {string} query Query string
|
||||||
|
* @returns {?string} Cached keyword if one fits query, else empty string if cached keywords exist, null if cached keywords do not exist
|
||||||
|
*/
|
||||||
|
function getCachedKeyword(query) {
|
||||||
|
if (!cachedNames) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let i;
|
||||||
|
|
||||||
|
for (i = query.length; i > 0; i--) {
|
||||||
|
const startStr = query.substr(0, i);
|
||||||
|
if (cachedNames[startStr]) {
|
||||||
|
return startStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get names matching query
|
||||||
|
* @param {string} query Query string
|
||||||
|
* @param {Object.<number, MentionsData>} items List of {@link MentionsData} items
|
||||||
|
* @param {string} searchKey Key to use for matching items
|
||||||
|
* @returns {Object.<number, MentionsData>} List of {@link MentionsData} items filtered with query and by searchKey
|
||||||
|
*/
|
||||||
|
function getMatchedNames(query, items, searchKey) {
|
||||||
|
let i;
|
||||||
|
let itemsLength;
|
||||||
|
const matchedNames = [];
|
||||||
|
for (i = 0, itemsLength = items.length; i < itemsLength; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (isItemMatched(query, item, searchKey)) {
|
||||||
|
matchedNames.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether item is matched by query
|
||||||
|
*
|
||||||
|
* @param {string} query Search query string
|
||||||
|
* @param {MentionsData} item Mentions data item
|
||||||
|
* @param {string }searchKey Key to use for matching items
|
||||||
|
* @return {boolean} True if items is matched, false otherwise
|
||||||
|
*/
|
||||||
|
function isItemMatched(query, item, searchKey) {
|
||||||
|
return String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase()) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter items by search query
|
||||||
|
*
|
||||||
|
* @param {string} query Search query string
|
||||||
|
* @param {Object.<number, MentionsData>} items List of {@link MentionsData} items
|
||||||
|
* @return {Object.<number, MentionsData>} List of {@link MentionsData} items filtered with query and by searchKey
|
||||||
|
*/
|
||||||
|
function itemFilter(query, items) {
|
||||||
|
let i;
|
||||||
|
let len;
|
||||||
|
const highestPriorities = { u: 1, g: 1 };
|
||||||
|
const _unsorted = { u: {}, g: {} };
|
||||||
|
const _exactMatch = [];
|
||||||
|
let _results = [];
|
||||||
|
|
||||||
|
// Reduce the items array to the relevant ones
|
||||||
|
items = getMatchedNames(query, items, 'name');
|
||||||
|
|
||||||
|
// Group names by their types and calculate priorities
|
||||||
|
for (i = 0, len = items.length; i < len; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
|
||||||
|
// Check for unsupported type - in general, this should never happen
|
||||||
|
if (!_unsorted[item.type]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current user doesn't want to mention themselves with "@" in most cases -
|
||||||
|
// do not waste list space with their own name
|
||||||
|
if (item.type === 'u' && item.id === String(mentionUserId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact matches should not be prioritised - they always come first
|
||||||
|
if (item.name === query) {
|
||||||
|
_exactMatch.push(items[i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the item hasn't been added yet - add it
|
||||||
|
if (!_unsorted[item.type][item.id]) {
|
||||||
|
_unsorted[item.type][item.id] = item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority is calculated as the sum of priorities from different sources
|
||||||
|
_unsorted[item.type][item.id].priority += parseFloat(item.priority.toString());
|
||||||
|
|
||||||
|
// Calculate the highest priority - we'll give it to group names
|
||||||
|
highestPriorities[item.type] = Math.max(highestPriorities[item.type], _unsorted[item.type][item.id].priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All types of names should come at the same level of importance,
|
||||||
|
// otherwise they will be unlikely to be shown
|
||||||
|
// That's why we normalize priorities and push names to a single results array
|
||||||
|
$.each([ 'u', 'g' ], (key, type) => {
|
||||||
|
if (_unsorted[type]) {
|
||||||
|
$.each(_unsorted[type], (name, value) => {
|
||||||
|
// Normalize priority
|
||||||
|
value.priority /= highestPriorities[type];
|
||||||
|
|
||||||
|
// Add item to all results
|
||||||
|
_results.push(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort names by priorities - higher values come first
|
||||||
|
_results = _results.sort((a, b) => {
|
||||||
|
return b.priority - a.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exact match is the most important - should come above anything else
|
||||||
|
$.each(_exactMatch, (name, value) => {
|
||||||
|
_results.unshift(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return _results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remoteFilter callback filter function
|
||||||
|
* @param {string} query Query string
|
||||||
|
* @param {function} callback Callback function for filtered items
|
||||||
|
*/
|
||||||
|
function remoteFilter(query, callback) {
|
||||||
|
/*
|
||||||
|
* Do not make a new request until the previous one for the same query is returned
|
||||||
|
* This fixes duplicate server queries e.g. when arrow keys are pressed
|
||||||
|
*/
|
||||||
|
if (queryInProgress === query) {
|
||||||
|
setTimeout(() => {
|
||||||
|
remoteFilter(query, callback);
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedKeyword = getCachedKeyword(query);
|
||||||
|
const cachedNamesForQuery = (cachedKeyword !== null) ? cachedNames[cachedKeyword] : null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use cached values when we can:
|
||||||
|
* 1) There are some names in the cache relevant for the query
|
||||||
|
* (cache for the query with the same first characters contains some data)
|
||||||
|
* 2) We have enough names to display OR
|
||||||
|
* all relevant names have been fetched from the server
|
||||||
|
*/
|
||||||
|
if (cachedNamesForQuery &&
|
||||||
|
(getMatchedNames(query, cachedNamesForQuery, cachedSearchKey).length >= mentionNamesLimit ||
|
||||||
|
cachedAll[cachedKeyword])) {
|
||||||
|
callback(cachedNamesForQuery);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queryInProgress = query;
|
||||||
|
|
||||||
|
const params = { keyword: query, topic_id: mentionTopicId, _referer: location.href };
|
||||||
|
$.getJSON(mentionURL, params, data => {
|
||||||
|
cachedNames[query] = data.names;
|
||||||
|
cachedAll[query] = data.all;
|
||||||
|
callback(data.names);
|
||||||
|
}).always(() => {
|
||||||
|
queryInProgress = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate menu item HTML representation. Also ensures that mention-list
|
||||||
|
* class is set for unordered list in mention container
|
||||||
|
*
|
||||||
|
* @param {object} data Item data
|
||||||
|
* @returns {string} HTML representation of menu item
|
||||||
|
*/
|
||||||
|
function menuItemTemplate(data) {
|
||||||
|
const itemData = data;
|
||||||
|
const avatar = getAvatar(itemData.avatar, itemData.type);
|
||||||
|
const rank = (itemData.rank) ? '<span class=\'mention-rank\'>' + itemData.rank + '</span>' : '';
|
||||||
|
const $mentionContainer = $('.' + tribute.current.collection.containerClass);
|
||||||
|
|
||||||
|
if (typeof $mentionContainer !== 'undefined' && $mentionContainer.children('ul').hasClass('mention-list') === false) {
|
||||||
|
$mentionContainer.children('ul').addClass('mention-list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<span class=\'mention-media\'>' + avatar + '</span><span class=\'mention-name\'>' + itemData.name + rank + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isEnabled = function() {
|
||||||
|
return $mentionDataContainer.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handle = function(textarea) {
|
||||||
|
tribute = new Tribute({
|
||||||
|
trigger: '@',
|
||||||
|
allowSpaces: true,
|
||||||
|
containerClass: 'mention-container',
|
||||||
|
selectClass: 'is-active',
|
||||||
|
itemClass: 'mention-item',
|
||||||
|
menuItemTemplate,
|
||||||
|
selectTemplate(item) {
|
||||||
|
return '[mention=' + item.type + ':' + item.id + ']' + item.name + '[/mention]';
|
||||||
|
},
|
||||||
|
menuItemLimit: mentionNamesLimit,
|
||||||
|
values(text, cb) {
|
||||||
|
remoteFilter(text, users => cb(users));
|
||||||
|
},
|
||||||
|
lookup(element) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(element, 'name') ? element.name : '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tribute.search.filter = itemFilter;
|
||||||
|
|
||||||
|
tribute.attach($(textarea));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
phpbb.mentions = new Mentions();
|
||||||
|
|
||||||
|
$(document).ready(() => {
|
||||||
|
const textarea = getEditorTextArea();
|
||||||
|
|
||||||
|
if (typeof textarea === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phpbb.mentions.isEnabled()) {
|
||||||
|
phpbb.mentions.handle(textarea);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(jQuery);
|
|
@ -26,8 +26,9 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/tribute.min.js -->
|
|
||||||
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/editor.js -->
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/editor.js -->
|
||||||
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/tribute.min.js -->
|
||||||
|
<!-- INCLUDEJS {T_ASSETS_PATH}/javascript/mentions.js -->
|
||||||
|
|
||||||
<!-- IF S_BBCODE_ALLOWED -->
|
<!-- IF S_BBCODE_ALLOWED -->
|
||||||
<div id="colour_palette" style="display: none;">
|
<div id="colour_palette" style="display: none;">
|
||||||
|
|
Loading…
Add table
Reference in a new issue