diff --git a/phpBB/adm/style/acp_posting_buttons.html b/phpBB/adm/style/acp_posting_buttons.html index 82c35dc65c..c99a168f12 100644 --- a/phpBB/adm/style/acp_posting_buttons.html +++ b/phpBB/adm/style/acp_posting_buttons.html @@ -8,8 +8,9 @@ // ]]> - + +
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}"> diff --git a/phpBB/assets/javascript/editor.js b/phpBB/assets/javascript/editor.js index a96a291a20..d1734564b5 100644 --- a/phpBB/assets/javascript/editor.js +++ b/phpBB/assets/javascript/editor.js @@ -383,317 +383,39 @@ function getCaretPosition(txtarea) { 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($) { '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 ''; - } else { - return ''; - } - } - - /** - * 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 '' + avatarData.title + ''; - } - } - - return data.html === '' && data.src === '' ? defaultAvatar(type) : "" + avatarToHtml(data)+ ""; - } - - /** - * 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.} items List of {@link MentionsData} items - * @param {string} searchKey Key to use for matching items - * @returns {Object.} 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.} items List of {@link MentionsData} items - * @return {Object.} 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) ? "" + itemData.rank + "" : ''; - const $mentionContainer = $('.' + tribute.current.collection.containerClass); - - if (typeof $mentionContainer !== 'undefined' && $mentionContainer.children('ul').hasClass('mention-list') === false) { - $mentionContainer.children('ul').addClass('mention-list'); - } - - return "" + avatar + "" + itemData.name + rank + ""; - } - - 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 textarea; + const textarea = getEditorTextArea(); - // find textarea, make sure browser supports necessary functions - if (document.forms[form_name]) { - doc = document; - } else { - doc = opener.document; - } - - if (!doc.forms[form_name]) { + if (typeof textarea === 'undefined') { return; } - textarea = doc.forms[form_name].elements[text_name]; - /** * Allow to use tab character when typing code * Keep indentation of last line of code when typing code @@ -704,10 +426,6 @@ function getCaretPosition(txtarea) { phpbb.showDragNDrop(textarea); } - if (phpbb.mentions.isEnabled()) { - phpbb.mentions.handle(textarea); - } - $('textarea').on('keydown', function (e) { if (e.which === 13 && (e.metaKey || e.ctrlKey)) { $(this).closest('form').find(':submit').click(); diff --git a/phpBB/assets/javascript/mentions.js b/phpBB/assets/javascript/mentions.js new file mode 100644 index 0000000000..42de121b6b --- /dev/null +++ b/phpBB/assets/javascript/mentions.js @@ -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 ''; + } + + return ''; + } + + /** + * 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.title + ''; + } + + return avatarData.html; + }; + + return data.html === '' && data.src === '' ? defaultAvatar(type) : '' + avatarToHtml(data) + ''; + } + + /** + * 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.} items List of {@link MentionsData} items + * @param {string} searchKey Key to use for matching items + * @returns {Object.} 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.} items List of {@link MentionsData} items + * @return {Object.} 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) ? '' + itemData.rank + '' : ''; + const $mentionContainer = $('.' + tribute.current.collection.containerClass); + + if (typeof $mentionContainer !== 'undefined' && $mentionContainer.children('ul').hasClass('mention-list') === false) { + $mentionContainer.children('ul').addClass('mention-list'); + } + + return '' + avatar + '' + itemData.name + rank + ''; + } + + 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); diff --git a/phpBB/styles/prosilver/template/posting_buttons.html b/phpBB/styles/prosilver/template/posting_buttons.html index 2a76bd272e..fa36310198 100644 --- a/phpBB/styles/prosilver/template/posting_buttons.html +++ b/phpBB/styles/prosilver/template/posting_buttons.html @@ -26,8 +26,9 @@ } - + +