[ticket/13713] Add mentions code to mentions.js

PHPBB3-13713
This commit is contained in:
Marc Alexander 2021-05-17 22:14:11 +02:00
parent 6eeb22cdb5
commit a86d9699a5
No known key found for this signature in database
GPG key ID: 50E0D2423696F995
4 changed files with 337 additions and 308 deletions

View file

@ -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 -->>

View file

@ -383,303 +383,14 @@ function getCaretPosition(txtarea) {
return caretPos; return caretPos;
} }
/* import Tribute from './jquery.tribute'; */
(function($) { /**
'use strict'; * Get editor text area element
/**
* 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 * @return {HTMLElement|null} Text area element or null if textarea couldn't be found
* @param {string} type
* @return {string} Avatar HTML
*/ */
function getAvatar(data, type) { function getEditorTextArea() {
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() {
let doc; let doc;
let textarea;
// find textarea, make sure browser supports necessary functions // find textarea, make sure browser supports necessary functions
if (document.forms[form_name]) { if (document.forms[form_name]) {
@ -692,7 +403,18 @@ function getCaretPosition(txtarea) {
return; return;
} }
textarea = doc.forms[form_name].elements[text_name]; return doc.forms[form_name].elements[text_name];
}
(function($) {
'use strict';
$(document).ready(function() {
const textarea = getEditorTextArea();
if (typeof textarea === 'undefined') {
return;
}
/** /**
* Allow to use tab character when typing code * Allow to use tab character 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();

View 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);

View file

@ -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;">