From: Alexander Ebert Date: Mon, 2 Nov 2020 19:55:49 +0000 (+0100) Subject: Convert `Ui/Reaction/Handler` to TypeScript X-Git-Tag: 5.4.0_Alpha_1~647^2~3 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=1dbe7a6f68083ca5e72cdaa62b46ad0bf2109bf0;p=GitHub%2FWoltLab%2FWCF.git Convert `Ui/Reaction/Handler` to TypeScript --- diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js index 3a46061373..f488da7ef2 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Reaction/Handler.js @@ -1,150 +1,138 @@ /** * Provides interface elements to use reactions. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Ui/Reaction/Handler + * @author Joshua Ruesweg + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Ui/Reaction/Handler * @since 5.2 */ -define([ - 'Ajax', - 'Core', - 'Dictionary', - 'Dom/ChangeListener', - 'Dom/Util', - 'Ui/Alignment', - 'Ui/CloseOverlay', - 'Ui/Screen', - 'WoltLabSuite/Core/Ui/Reaction/CountButtons', -], function (Ajax, Core, Dictionary, DomChangeListener, DomUtil, UiAlignment, UiCloseOverlay, UiScreen, CountButtons) { +define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Dom/Change/Listener", "../../Dom/Util", "../Alignment", "../CloseOverlay", "../Screen", "./CountButtons"], function (require, exports, tslib_1, Ajax, Core, Listener_1, Util_1, UiAlignment, CloseOverlay_1, UiScreen, CountButtons_1) { "use strict"; - /** - * @constructor - */ - function UiReactionHandler(objectType, options) { this.init(objectType, options); } - UiReactionHandler.prototype = { + Ajax = tslib_1.__importStar(Ajax); + Core = tslib_1.__importStar(Core); + Listener_1 = tslib_1.__importDefault(Listener_1); + Util_1 = tslib_1.__importDefault(Util_1); + UiAlignment = tslib_1.__importStar(UiAlignment); + CloseOverlay_1 = tslib_1.__importDefault(CloseOverlay_1); + UiScreen = tslib_1.__importStar(UiScreen); + CountButtons_1 = tslib_1.__importDefault(CountButtons_1); + class UiReactionHandler { /** * Initializes the reaction handler. - * - * @param {string} objectType object type - * @param {object} options initialization options */ - init: function (objectType, options) { - if (options.containerSelector === '') { + constructor(objectType, opts) { + this._cache = new Map(); + this._containers = new Map(); + this._objects = new Map(); + this._popoverCurrentObjectId = 0; + if (!opts.containerSelector) { throw new Error("[WoltLabSuite/Core/Ui/Reaction/Handler] Expected a non-empty string for option 'containerSelector'."); } - this._containers = new Dictionary(); this._objectType = objectType; - this._cache = new Dictionary(); - this._objects = new Dictionary(); - this._popoverCurrentObjectId = 0; this._popover = null; this._popoverContent = null; this._options = Core.extend({ // selectors - buttonSelector: '.reactButton', - containerSelector: '', + buttonSelector: ".reactButton", + containerSelector: "", isButtonGroupNavigation: false, isSingleItem: false, // other stuff parameters: { - data: {} - } - }, options); - this.initReactButtons(options, objectType); - this.countButtons = new CountButtons(this._objectType, this._options); - DomChangeListener.add('WoltLabSuite/Core/Ui/Reaction/Handler-' + objectType, this.initReactButtons.bind(this)); - UiCloseOverlay.add('WoltLabSuite/Core/Ui/Reaction/Handler', this._closePopover.bind(this)); - }, + data: {}, + }, + }, opts); + this.initReactButtons(); + this.countButtons = new CountButtons_1.default(this._objectType, this._options); + Listener_1.default.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons()); + CloseOverlay_1.default.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover()); + } /** * Initializes all applicable react buttons with the given selector. */ - initReactButtons: function () { - var element, elements = elBySelAll(this._options.containerSelector), elementData, triggerChange = false, objectId; - for (var i = 0, length = elements.length; i < length; i++) { - element = elements[i]; - if (this._containers.has(DomUtil.identify(element))) { - continue; + initReactButtons() { + let triggerChange = false; + document.querySelectorAll(this._options.containerSelector).forEach((element) => { + const elementId = Util_1.default.identify(element); + if (this._containers.has(elementId)) { + return; } - objectId = ~~elData(element, 'object-id'); - elementData = { + const objectId = ~~element.dataset.objectId; + const elementData = { reactButton: null, objectId: objectId, - element: element + element: element, }; - this._containers.set(DomUtil.identify(element), elementData); + this._containers.set(elementId, elementData); this._initReactButton(element, elementData); - var objects = []; - if (this._objects.has(objectId)) { - objects = this._objects.get(objectId); - } + const objects = this._objects.get(objectId) || []; objects.push(elementData); this._objects.set(objectId, objects); triggerChange = true; - } + }); if (triggerChange) { - DomChangeListener.trigger(); + Listener_1.default.trigger(); } - }, + } /** * Initializes a specific react button. */ - _initReactButton: function (element, elementData) { + _initReactButton(element, elementData) { if (this._options.isSingleItem) { - elementData.reactButton = elBySel(this._options.buttonSelector); + elementData.reactButton = document.querySelector(this._options.buttonSelector); } else { - elementData.reactButton = elBySel(this._options.buttonSelector, element); + elementData.reactButton = element.querySelector(this._options.buttonSelector); } - if (elementData.reactButton === null || elementData.reactButton.length === 0) { - // The element may have no react button. + if (elementData.reactButton === null) { + // The element may have no react button. return; } - //noinspection JSUnresolvedVariable - if (Object.keys(REACTION_TYPES).length === 1) { - //noinspection JSUnresolvedVariable - var reaction = REACTION_TYPES[Object.keys(REACTION_TYPES)[0]]; + const availableReactions = Object.values(window.REACTION_TYPES); + if (availableReactions.length === 1) { + const reaction = availableReactions[0]; elementData.reactButton.title = reaction.title; - var textSpan = elBySel('.invisible', elementData.reactButton); - textSpan.innerText = reaction.title; + const textSpan = elementData.reactButton.querySelector(".invisible"); + textSpan.textContent = reaction.title; } - elementData.reactButton.addEventListener('click', this._toggleReactPopover.bind(this, elementData.objectId, elementData.reactButton)); - }, - _updateReactButton: function (objectID, reactionTypeID) { - this._objects.get(objectID).forEach(function (elementData) { + elementData.reactButton.addEventListener("click", (ev) => { + this._toggleReactPopover(elementData.objectId, elementData.reactButton, ev); + }); + } + _updateReactButton(objectID, reactionTypeID) { + this._objects.get(objectID).forEach((elementData) => { if (elementData.reactButton !== null) { if (reactionTypeID) { - elementData.reactButton.classList.add('active'); - elData(elementData.reactButton, 'reaction-type-id', reactionTypeID); + elementData.reactButton.classList.add("active"); + elementData.reactButton.dataset.reactionTypeId = reactionTypeID.toString(); } else { - elData(elementData.reactButton, 'reaction-type-id', 0); - elementData.reactButton.classList.remove('active'); + elementData.reactButton.dataset.reactionTypeId = "0"; + elementData.reactButton.classList.remove("active"); } } }); - }, - _markReactionAsActive: function () { - var reactionTypeID = null; - this._objects.get(this._popoverCurrentObjectId).forEach(function (element) { + } + _markReactionAsActive() { + let reactionTypeID = 0; + this._objects.get(this._popoverCurrentObjectId).forEach((element) => { if (element.reactButton !== null) { - reactionTypeID = ~~elData(element.reactButton, 'reaction-type-id'); + reactionTypeID = ~~element.reactButton.dataset.reactionTypeId; } }); - if (reactionTypeID === null) { + if (!reactionTypeID) { throw new Error("Unable to find react button for current popover."); } // Clear the old active state. - elBySelAll('.reactionTypeButton.active', this._getPopover(), function (element) { - element.classList.remove('active'); - }); - var scrollableContainer = elBySel('.reactionPopoverContent', this._getPopover()); + const popover = this._getPopover(); + popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active")); + const scrollableContainer = popover.querySelector(".reactionPopoverContent"); if (reactionTypeID) { - var reactionTypeButton = elBySel('.reactionTypeButton[data-reaction-type-id="' + reactionTypeID + '"]', this._getPopover()); - reactionTypeButton.classList.add('active'); - if (~~elData(reactionTypeButton, 'is-assignable') === 0) { - elShow(reactionTypeButton); + const reactionTypeButton = popover.querySelector(`.reactionTypeButton[data-reaction-type-id="${reactionTypeID}"]`); + reactionTypeButton.classList.add("active"); + if (~~reactionTypeButton.dataset.isAssignable === 0) { + Util_1.default.show(reactionTypeButton); } this._scrollReactionIntoView(scrollableContainer, reactionTypeButton); } @@ -152,8 +140,8 @@ define([ // The "first" reaction is positioned as close as possible to the toggle button, // which means that we need to scroll the list to the bottom if the popover is // displayed above the toggle button. - if (UiScreen.is('screen-xs')) { - if (this._getPopover().classList.contains('inverseOrder')) { + if (UiScreen.is("screen-xs")) { + if (popover.classList.contains("inverseOrder")) { scrollableContainer.scrollTop = 0; } else { @@ -161,8 +149,8 @@ define([ } } } - }, - _scrollReactionIntoView: function (scrollableContainer, reactionTypeButton) { + } + _scrollReactionIntoView(scrollableContainer, reactionTypeButton) { // Do not scroll if the button is located in the upper 75%. if (reactionTypeButton.offsetTop < scrollableContainer.clientHeight * 0.75) { scrollableContainer.scrollTop = 0; @@ -172,25 +160,21 @@ define([ // the maximum possible offset value. We can abuse this behavior by calculating // the values to place the selected reaction in the center of the popover, // regardless of the offset being out of range. - scrollableContainer.scrollTop = reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2; + scrollableContainer.scrollTop = + reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2; } - }, + } /** * Toggle the visibility of the react popover. - * - * @param {int} objectId - * @param {Element} element - * @param {?Event} event */ - _toggleReactPopover: function (objectId, element, event) { + _toggleReactPopover(objectId, element, event) { if (event !== null) { event.preventDefault(); event.stopPropagation(); } - //noinspection JSUnresolvedVariable - if (Object.keys(REACTION_TYPES).length === 1) { - //noinspection JSUnresolvedVariable - var reaction = REACTION_TYPES[Object.keys(REACTION_TYPES)[0]]; + const availableReactions = Object.values(window.REACTION_TYPES); + if (availableReactions.length === 1) { + const reaction = availableReactions[0]; this._popoverCurrentObjectId = objectId; this._react(reaction.reactionTypeID); } @@ -199,139 +183,129 @@ define([ this._openReactPopover(objectId, element); } else { - this._closePopover(objectId, element); + this._closePopover(); } } - }, + } /** * Opens the react popover for a specific react button. - * - * @param {int} objectId objectId of the element - * @param {Element} element container element */ - _openReactPopover: function (objectId, element) { + _openReactPopover(objectId, element) { if (this._popoverCurrentObjectId !== 0) { this._closePopover(); } this._popoverCurrentObjectId = objectId; UiAlignment.set(this._getPopover(), element, { pointer: true, - horizontal: (this._options.isButtonGroupNavigation) ? 'left' : 'center', - vertical: UiScreen.is('screen-xs') ? 'bottom' : 'top' + horizontal: this._options.isButtonGroupNavigation ? "left" : "center", + vertical: UiScreen.is("screen-xs") ? "bottom" : "top", }); if (this._options.isButtonGroupNavigation) { - element.closest('nav').style.setProperty('opacity', '1', ''); + element.closest("nav").style.setProperty("opacity", "1", ""); } - var popover = this._getPopover(); + const popover = this._getPopover(); // The popover could be rendered below the input field on mobile, in which case // the "first" button is displayed at the bottom and thus farthest away. Reversing // the display order will restore the logic by placing the "first" button as close // to the react button as possible. - var inverseOrder = popover.style.getPropertyValue('bottom') === 'auto'; - popover.classList[inverseOrder ? 'add' : 'remove']('inverseOrder'); + const inverseOrder = popover.style.getPropertyValue("bottom") === "auto"; + if (inverseOrder) { + popover.classList.add("inverseOrder"); + } + else { + popover.classList.remove("inverseOrder"); + } this._markReactionAsActive(); this._rebuildOverflowIndicator(); - popover.classList.remove('forceHide'); - popover.classList.add('active'); - }, + popover.classList.remove("forceHide"); + popover.classList.add("active"); + } /** * Returns the react popover element. - * - * @returns {Element} */ - _getPopover: function () { + _getPopover() { if (this._popover == null) { - this._popover = elCreate('div'); - this._popover.className = 'reactionPopover forceHide'; - this._popoverContent = elCreate('div'); - this._popoverContent.className = 'reactionPopoverContent'; - var popoverContentHTML = elCreate('ul'); - popoverContentHTML.className = 'reactionTypeButtonList'; - var sortedReactionTypes = this._getSortedReactionTypes(); - for (var key in sortedReactionTypes) { - if (!sortedReactionTypes.hasOwnProperty(key)) - continue; - var reactionType = sortedReactionTypes[key]; - var reactionTypeItem = elCreate('li'); - reactionTypeItem.className = 'reactionTypeButton jsTooltip'; - elData(reactionTypeItem, 'reaction-type-id', reactionType.reactionTypeID); - elData(reactionTypeItem, 'title', reactionType.title); - elData(reactionTypeItem, 'is-assignable', ~~reactionType.isAssignable); + this._popover = document.createElement("div"); + this._popover.className = "reactionPopover forceHide"; + this._popoverContent = document.createElement("div"); + this._popoverContent.className = "reactionPopoverContent"; + const popoverContentHTML = document.createElement("ul"); + popoverContentHTML.className = "reactionTypeButtonList"; + this._getSortedReactionTypes().forEach((reactionType) => { + const reactionTypeItem = document.createElement("li"); + reactionTypeItem.className = "reactionTypeButton jsTooltip"; + reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString(); + reactionTypeItem.dataset.title = reactionType.title; + reactionTypeItem.dataset.isAssignable = reactionType.isAssignable.toString(); reactionTypeItem.title = reactionType.title; - var reactionTypeItemSpan = elCreate('span'); - reactionTypeItemSpan.className = 'reactionTypeButtonTitle'; + const reactionTypeItemSpan = document.createElement("span"); + reactionTypeItemSpan.className = "reactionTypeButtonTitle"; reactionTypeItemSpan.innerHTML = reactionType.title; - //noinspection JSUnresolvedVariable reactionTypeItem.innerHTML = reactionType.renderedIcon; reactionTypeItem.appendChild(reactionTypeItemSpan); - reactionTypeItem.addEventListener('click', this._react.bind(this, reactionType.reactionTypeID)); + reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID)); if (!reactionType.isAssignable) { - elHide(reactionTypeItem); + Util_1.default.hide(reactionTypeItem); } popoverContentHTML.appendChild(reactionTypeItem); - } + }); this._popoverContent.appendChild(popoverContentHTML); - this._popoverContent.addEventListener('scroll', this._rebuildOverflowIndicator.bind(this), { passive: true }); + this._popoverContent.addEventListener("scroll", () => this._rebuildOverflowIndicator(), { passive: true }); this._popover.appendChild(this._popoverContent); - var pointer = elCreate('span'); - pointer.className = 'elementPointer'; - pointer.appendChild(elCreate('span')); + const pointer = document.createElement("span"); + pointer.className = "elementPointer"; + pointer.appendChild(document.createElement("span")); this._popover.appendChild(pointer); document.body.appendChild(this._popover); - DomChangeListener.trigger(); + Listener_1.default.trigger(); } return this._popover; - }, - _rebuildOverflowIndicator: function () { - var hasTopOverflow = this._popoverContent.scrollTop > 0; - this._popoverContent.classList[hasTopOverflow ? 'add' : 'remove']('overflowTop'); - var hasBottomOverflow = this._popoverContent.scrollTop + this._popoverContent.clientHeight < this._popoverContent.scrollHeight; - this._popoverContent.classList[hasBottomOverflow ? 'add' : 'remove']('overflowBottom'); - }, + } + _rebuildOverflowIndicator() { + const popoverContent = this._popoverContent; + const hasTopOverflow = popoverContent.scrollTop > 0; + if (hasTopOverflow) { + popoverContent.classList.add("overflowTop"); + } + else { + popoverContent.classList.remove("overflowTop"); + } + const hasBottomOverflow = popoverContent.scrollTop + popoverContent.clientHeight < popoverContent.scrollHeight; + if (hasBottomOverflow) { + popoverContent.classList.add("overflowBottom"); + } + else { + popoverContent.classList.remove("overflowBottom"); + } + } /** * Sort the reaction types by the showOrder field. - * - * @returns {Array} the reaction types sorted by showOrder */ - _getSortedReactionTypes: function () { - var sortedReactionTypes = []; - // convert our reaction type object to an array - //noinspection JSUnresolvedVariable - for (var key in REACTION_TYPES) { - //noinspection JSUnresolvedVariable - if (REACTION_TYPES.hasOwnProperty(key)) { - //noinspection JSUnresolvedVariable - sortedReactionTypes.push(REACTION_TYPES[key]); - } - } - // sort the array - sortedReactionTypes.sort(function (a, b) { - //noinspection JSUnresolvedVariable - return a.showOrder - b.showOrder; - }); - return sortedReactionTypes; - }, + _getSortedReactionTypes() { + return Object.values(window.REACTION_TYPES).sort((a, b) => a.showOrder - b.showOrder); + } /** * Closes the react popover. */ - _closePopover: function () { + _closePopover() { if (this._popoverCurrentObjectId !== 0) { - this._getPopover().classList.remove('active'); - elBySelAll('.reactionTypeButton[data-is-assignable="0"]', this._getPopover(), elHide); + const popover = this._getPopover(); + popover.classList.remove("active"); + popover + .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]') + .forEach((el) => Util_1.default.hide(el)); if (this._options.isButtonGroupNavigation) { - this._objects.get(this._popoverCurrentObjectId).forEach(function (elementData) { - elementData.reactButton.closest('nav').style.cssText = ""; + this._objects.get(this._popoverCurrentObjectId).forEach((elementData) => { + elementData.reactButton.closest("nav").style.cssText = ""; }); } this._popoverCurrentObjectId = 0; } - }, + } /** * React with the given reactionTypeId on an object. - * - * @param {init} reactionTypeId */ - _react: function (reactionTypeId) { + _react(reactionTypeId) { if (~~this._popoverCurrentObjectId === 0) { // Double clicking the reaction will cause the first click to go through, but // causes the second to fail because the overlay is already closing. @@ -341,23 +315,23 @@ define([ this._options.parameters.data.objectID = this._popoverCurrentObjectId; this._options.parameters.data.objectType = this._objectType; Ajax.api(this, { - parameters: this._options.parameters + parameters: this._options.parameters, }); this._closePopover(); - }, - _ajaxSuccess: function (data) { - //noinspection JSUnresolvedVariable + } + _ajaxSuccess(data) { this.countButtons.updateCountButtons(data.returnValues.objectID, data.returnValues.reactions); this._updateReactButton(data.returnValues.objectID, data.returnValues.reactionTypeID); - }, - _ajaxSetup: function () { + } + _ajaxSetup() { return { data: { - actionName: 'react', - className: '\\wcf\\data\\reaction\\ReactionAction' - } + actionName: "react", + className: "\\wcf\\data\\reaction\\ReactionAction", + }, }; } - }; + } + Core.enableLegacyInheritance(UiReactionHandler); return UiReactionHandler; }); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.js b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.js deleted file mode 100644 index f736d6feed..0000000000 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.js +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Provides interface elements to use reactions. - * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - * @module WoltLabSuite/Core/Ui/Reaction/Handler - * @since 5.2 - */ -define( - [ - 'Ajax', - 'Core', - 'Dictionary', - 'Dom/ChangeListener', - 'Dom/Util', - 'Ui/Alignment', - 'Ui/CloseOverlay', - 'Ui/Screen', - 'WoltLabSuite/Core/Ui/Reaction/CountButtons', - ], - function( - Ajax, - Core, - Dictionary, - DomChangeListener, - DomUtil, - UiAlignment, - UiCloseOverlay, - UiScreen, - CountButtons - ) { - "use strict"; - - /** - * @constructor - */ - function UiReactionHandler(objectType, options) { this.init(objectType, options); } - UiReactionHandler.prototype = { - /** - * Initializes the reaction handler. - * - * @param {string} objectType object type - * @param {object} options initialization options - */ - init: function(objectType, options) { - if (options.containerSelector === '') { - throw new Error("[WoltLabSuite/Core/Ui/Reaction/Handler] Expected a non-empty string for option 'containerSelector'."); - } - - this._containers = new Dictionary(); - this._objectType = objectType; - this._cache = new Dictionary(); - this._objects = new Dictionary(); - - this._popoverCurrentObjectId = 0; - - this._popover = null; - this._popoverContent = null; - - this._options = Core.extend({ - // selectors - buttonSelector: '.reactButton', - containerSelector: '', - isButtonGroupNavigation: false, - isSingleItem: false, - - // other stuff - parameters: { - data: {} - } - }, options); - - this.initReactButtons(options, objectType); - - this.countButtons = new CountButtons(this._objectType, this._options); - - DomChangeListener.add('WoltLabSuite/Core/Ui/Reaction/Handler-' + objectType, this.initReactButtons.bind(this)); - UiCloseOverlay.add('WoltLabSuite/Core/Ui/Reaction/Handler', this._closePopover.bind(this)); - }, - - /** - * Initializes all applicable react buttons with the given selector. - */ - initReactButtons: function() { - var element, elements = elBySelAll(this._options.containerSelector), elementData, triggerChange = false, objectId; - for (var i = 0, length = elements.length; i < length; i++) { - element = elements[i]; - if (this._containers.has(DomUtil.identify(element))) { - continue; - } - - objectId = ~~elData(element, 'object-id'); - elementData = { - reactButton: null, - objectId: objectId, - element: element - }; - - this._containers.set(DomUtil.identify(element), elementData); - this._initReactButton(element, elementData); - - var objects = []; - if (this._objects.has(objectId)) { - objects = this._objects.get(objectId); - } - - objects.push(elementData); - - this._objects.set(objectId, objects); - - triggerChange = true; - } - - if (triggerChange) { - DomChangeListener.trigger(); - } - }, - - - /** - * Initializes a specific react button. - */ - _initReactButton: function(element, elementData) { - if (this._options.isSingleItem) { - elementData.reactButton = elBySel(this._options.buttonSelector); - } - else { - elementData.reactButton = elBySel(this._options.buttonSelector, element); - } - - if (elementData.reactButton === null || elementData.reactButton.length === 0) { - // The element may have no react button. - return; - } - - //noinspection JSUnresolvedVariable - if (Object.keys(REACTION_TYPES).length === 1) { - //noinspection JSUnresolvedVariable - var reaction = REACTION_TYPES[Object.keys(REACTION_TYPES)[0]]; - elementData.reactButton.title = reaction.title; - var textSpan = elBySel('.invisible', elementData.reactButton); - textSpan.innerText = reaction.title; - } - - elementData.reactButton.addEventListener('click', this._toggleReactPopover.bind(this, elementData.objectId, elementData.reactButton)); - }, - - _updateReactButton: function(objectID, reactionTypeID) { - this._objects.get(objectID).forEach(function (elementData) { - if (elementData.reactButton !== null) { - if (reactionTypeID) { - elementData.reactButton.classList.add('active'); - elData(elementData.reactButton, 'reaction-type-id', reactionTypeID); - } - else { - elData(elementData.reactButton, 'reaction-type-id', 0); - elementData.reactButton.classList.remove('active'); - } - } - }); - }, - - _markReactionAsActive: function() { - var reactionTypeID = null; - this._objects.get(this._popoverCurrentObjectId).forEach(function (element) { - if (element.reactButton !== null) { - reactionTypeID = ~~elData(element.reactButton, 'reaction-type-id'); - } - }); - - if (reactionTypeID === null) { - throw new Error("Unable to find react button for current popover."); - } - - // Clear the old active state. - elBySelAll('.reactionTypeButton.active', this._getPopover(), function(element) { - element.classList.remove('active'); - }); - - var scrollableContainer = elBySel('.reactionPopoverContent', this._getPopover()); - if (reactionTypeID) { - var reactionTypeButton = elBySel('.reactionTypeButton[data-reaction-type-id="' + reactionTypeID + '"]', this._getPopover()); - reactionTypeButton.classList.add('active'); - - if (~~elData(reactionTypeButton, 'is-assignable') === 0) { - elShow(reactionTypeButton); - } - - this._scrollReactionIntoView(scrollableContainer, reactionTypeButton); - } - else { - // The "first" reaction is positioned as close as possible to the toggle button, - // which means that we need to scroll the list to the bottom if the popover is - // displayed above the toggle button. - if (UiScreen.is('screen-xs')) { - if (this._getPopover().classList.contains('inverseOrder')) { - scrollableContainer.scrollTop = 0; - } - else { - scrollableContainer.scrollTop = scrollableContainer.scrollHeight - scrollableContainer.clientHeight; - } - } - } - }, - - _scrollReactionIntoView: function (scrollableContainer, reactionTypeButton) { - // Do not scroll if the button is located in the upper 75%. - if (reactionTypeButton.offsetTop < scrollableContainer.clientHeight * 0.75) { - scrollableContainer.scrollTop = 0; - } - else { - // `Element.scrollTop` permits arbitrary values and will always clamp them to - // the maximum possible offset value. We can abuse this behavior by calculating - // the values to place the selected reaction in the center of the popover, - // regardless of the offset being out of range. - scrollableContainer.scrollTop = reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2; - } - }, - - /** - * Toggle the visibility of the react popover. - * - * @param {int} objectId - * @param {Element} element - * @param {?Event} event - */ - _toggleReactPopover: function(objectId, element, event) { - if (event !== null) { - event.preventDefault(); - event.stopPropagation(); - } - - //noinspection JSUnresolvedVariable - if (Object.keys(REACTION_TYPES).length === 1) { - //noinspection JSUnresolvedVariable - var reaction = REACTION_TYPES[Object.keys(REACTION_TYPES)[0]]; - this._popoverCurrentObjectId = objectId; - - this._react(reaction.reactionTypeID); - } - else { - if (this._popoverCurrentObjectId === 0 || this._popoverCurrentObjectId !== objectId) { - this._openReactPopover(objectId, element); - } - else { - this._closePopover(objectId, element); - } - } - }, - - /** - * Opens the react popover for a specific react button. - * - * @param {int} objectId objectId of the element - * @param {Element} element container element - */ - _openReactPopover: function(objectId, element) { - if (this._popoverCurrentObjectId !== 0) { - this._closePopover(); - } - - this._popoverCurrentObjectId = objectId; - - UiAlignment.set(this._getPopover(), element, { - pointer: true, - horizontal: (this._options.isButtonGroupNavigation) ? 'left' : 'center', - vertical: UiScreen.is('screen-xs') ? 'bottom' : 'top' - }); - - if (this._options.isButtonGroupNavigation) { - element.closest('nav').style.setProperty('opacity', '1', ''); - } - - var popover = this._getPopover(); - - // The popover could be rendered below the input field on mobile, in which case - // the "first" button is displayed at the bottom and thus farthest away. Reversing - // the display order will restore the logic by placing the "first" button as close - // to the react button as possible. - var inverseOrder = popover.style.getPropertyValue('bottom') === 'auto'; - popover.classList[inverseOrder ? 'add' : 'remove']('inverseOrder'); - - this._markReactionAsActive(); - - this._rebuildOverflowIndicator(); - - popover.classList.remove('forceHide'); - popover.classList.add('active'); - }, - - /** - * Returns the react popover element. - * - * @returns {Element} - */ - _getPopover: function() { - if (this._popover == null) { - this._popover = elCreate('div'); - this._popover.className = 'reactionPopover forceHide'; - - this._popoverContent = elCreate('div'); - this._popoverContent.className = 'reactionPopoverContent'; - - var popoverContentHTML = elCreate('ul'); - popoverContentHTML.className = 'reactionTypeButtonList'; - - var sortedReactionTypes = this._getSortedReactionTypes(); - - for (var key in sortedReactionTypes) { - if (!sortedReactionTypes.hasOwnProperty(key)) continue; - - var reactionType = sortedReactionTypes[key]; - - var reactionTypeItem = elCreate('li'); - reactionTypeItem.className = 'reactionTypeButton jsTooltip'; - elData(reactionTypeItem, 'reaction-type-id', reactionType.reactionTypeID); - elData(reactionTypeItem, 'title', reactionType.title); - elData(reactionTypeItem, 'is-assignable', ~~reactionType.isAssignable); - - reactionTypeItem.title = reactionType.title; - - var reactionTypeItemSpan = elCreate('span'); - reactionTypeItemSpan.className = 'reactionTypeButtonTitle'; - reactionTypeItemSpan.innerHTML = reactionType.title; - - //noinspection JSUnresolvedVariable - reactionTypeItem.innerHTML = reactionType.renderedIcon; - - reactionTypeItem.appendChild(reactionTypeItemSpan); - - reactionTypeItem.addEventListener('click', this._react.bind(this, reactionType.reactionTypeID)); - - if (!reactionType.isAssignable) { - elHide(reactionTypeItem); - } - - popoverContentHTML.appendChild(reactionTypeItem); - } - - this._popoverContent.appendChild(popoverContentHTML); - this._popoverContent.addEventListener('scroll', this._rebuildOverflowIndicator.bind(this), {passive: true}); - - this._popover.appendChild(this._popoverContent); - - var pointer = elCreate('span'); - pointer.className = 'elementPointer'; - pointer.appendChild(elCreate('span')); - this._popover.appendChild(pointer); - - document.body.appendChild(this._popover); - - DomChangeListener.trigger(); - } - - return this._popover; - }, - - _rebuildOverflowIndicator: function () { - var hasTopOverflow = this._popoverContent.scrollTop > 0; - this._popoverContent.classList[hasTopOverflow ? 'add' : 'remove']('overflowTop'); - - var hasBottomOverflow = this._popoverContent.scrollTop + this._popoverContent.clientHeight < this._popoverContent.scrollHeight; - this._popoverContent.classList[hasBottomOverflow ? 'add' : 'remove']('overflowBottom'); - }, - - /** - * Sort the reaction types by the showOrder field. - * - * @returns {Array} the reaction types sorted by showOrder - */ - _getSortedReactionTypes: function() { - var sortedReactionTypes = []; - - // convert our reaction type object to an array - //noinspection JSUnresolvedVariable - for (var key in REACTION_TYPES) { - //noinspection JSUnresolvedVariable - if (REACTION_TYPES.hasOwnProperty(key)) { - //noinspection JSUnresolvedVariable - sortedReactionTypes.push(REACTION_TYPES[key]); - } - } - - // sort the array - sortedReactionTypes.sort(function (a, b) { - //noinspection JSUnresolvedVariable - return a.showOrder - b.showOrder; - }); - - return sortedReactionTypes; - }, - - /** - * Closes the react popover. - */ - _closePopover: function() { - if (this._popoverCurrentObjectId !== 0) { - this._getPopover().classList.remove('active'); - - elBySelAll('.reactionTypeButton[data-is-assignable="0"]', this._getPopover(), elHide); - - if (this._options.isButtonGroupNavigation) { - this._objects.get(this._popoverCurrentObjectId).forEach(function (elementData) { - elementData.reactButton.closest('nav').style.cssText = ""; - }); - } - - this._popoverCurrentObjectId = 0; - } - }, - - /** - * React with the given reactionTypeId on an object. - * - * @param {init} reactionTypeId - */ - _react: function(reactionTypeId) { - if (~~this._popoverCurrentObjectId === 0) { - // Double clicking the reaction will cause the first click to go through, but - // causes the second to fail because the overlay is already closing. - return; - } - - this._options.parameters.reactionTypeID = reactionTypeId; - this._options.parameters.data.objectID = this._popoverCurrentObjectId; - this._options.parameters.data.objectType = this._objectType; - - Ajax.api(this, { - parameters: this._options.parameters - }); - - this._closePopover(); - }, - - _ajaxSuccess: function(data) { - //noinspection JSUnresolvedVariable - this.countButtons.updateCountButtons(data.returnValues.objectID, data.returnValues.reactions); - - this._updateReactButton(data.returnValues.objectID, data.returnValues.reactionTypeID); - }, - - _ajaxSetup: function() { - return { - data: { - actionName: 'react', - className: '\\wcf\\data\\reaction\\ReactionAction' - } - }; - } - }; - - return UiReactionHandler; - }); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts new file mode 100644 index 0000000000..a623878737 --- /dev/null +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Reaction/Handler.ts @@ -0,0 +1,446 @@ +/** + * Provides interface elements to use reactions. + * + * @author Joshua Ruesweg + * @copyright 2001-2019 WoltLab GmbH + * @license GNU Lesser General Public License + * @module WoltLabSuite/Core/Ui/Reaction/Handler + * @since 5.2 + */ + +import * as Ajax from "../../Ajax"; +import { AjaxCallbackSetup } from "../../Ajax/Data"; +import * as Core from "../../Core"; +import DomChangeListener from "../../Dom/Change/Listener"; +import DomUtil from "../../Dom/Util"; +import * as UiAlignment from "../Alignment"; +import UiCloseOverlay from "../CloseOverlay"; +import * as UiScreen from "../Screen"; +import CountButtons from "./CountButtons"; +import { Reaction, ReactionStats } from "./Data"; + +interface ReactionHandlerOptions { + // selectors + buttonSelector: string; + containerSelector: string; + isButtonGroupNavigation: boolean; + isSingleItem: boolean; + + // other stuff + parameters: { + data: { + [key: string]: unknown; + }; + reactionTypeID?: number; + }; +} + +interface ElementData { + reactButton: HTMLElement | null; + objectId: number; + element: HTMLElement; +} + +interface AjaxResponse { + returnValues: { + objectID: number; + objectType: string; + reactions: ReactionStats; + reactionTypeID: number; + reputationCount: number; + }; +} + +class UiReactionHandler { + readonly countButtons: CountButtons; + protected readonly _cache = new Map(); + protected readonly _containers = new Map(); + protected readonly _options: ReactionHandlerOptions; + protected readonly _objects = new Map(); + protected readonly _objectType: string; + protected _popoverCurrentObjectId = 0; + protected _popover: HTMLElement | null; + protected _popoverContent: HTMLElement | null; + + /** + * Initializes the reaction handler. + */ + constructor(objectType: string, opts: ReactionHandlerOptions) { + if (!opts.containerSelector) { + throw new Error( + "[WoltLabSuite/Core/Ui/Reaction/Handler] Expected a non-empty string for option 'containerSelector'.", + ); + } + + this._objectType = objectType; + + this._popover = null; + this._popoverContent = null; + + this._options = Core.extend( + { + // selectors + buttonSelector: ".reactButton", + containerSelector: "", + isButtonGroupNavigation: false, + isSingleItem: false, + + // other stuff + parameters: { + data: {}, + }, + }, + opts, + ) as ReactionHandlerOptions; + + this.initReactButtons(); + + this.countButtons = new CountButtons(this._objectType, this._options); + + DomChangeListener.add(`WoltLabSuite/Core/Ui/Reaction/Handler-${objectType}`, () => this.initReactButtons()); + UiCloseOverlay.add("WoltLabSuite/Core/Ui/Reaction/Handler", () => this._closePopover()); + } + + /** + * Initializes all applicable react buttons with the given selector. + */ + initReactButtons(): void { + let triggerChange = false; + + document.querySelectorAll(this._options.containerSelector).forEach((element: HTMLElement) => { + const elementId = DomUtil.identify(element); + if (this._containers.has(elementId)) { + return; + } + + const objectId = ~~element.dataset.objectId!; + const elementData: ElementData = { + reactButton: null, + objectId: objectId, + element: element, + }; + + this._containers.set(elementId, elementData); + this._initReactButton(element, elementData); + + const objects = this._objects.get(objectId) || []; + + objects.push(elementData); + + this._objects.set(objectId, objects); + + triggerChange = true; + }); + + if (triggerChange) { + DomChangeListener.trigger(); + } + } + + /** + * Initializes a specific react button. + */ + _initReactButton(element: HTMLElement, elementData: ElementData): void { + if (this._options.isSingleItem) { + elementData.reactButton = document.querySelector(this._options.buttonSelector) as HTMLElement; + } else { + elementData.reactButton = element.querySelector(this._options.buttonSelector) as HTMLElement; + } + + if (elementData.reactButton === null) { + // The element may have no react button. + return; + } + + const availableReactions = Object.values(window.REACTION_TYPES); + if (availableReactions.length === 1) { + const reaction = availableReactions[0]; + elementData.reactButton.title = reaction.title; + const textSpan = elementData.reactButton.querySelector(".invisible")!; + textSpan.textContent = reaction.title; + } + + elementData.reactButton.addEventListener("click", (ev) => { + this._toggleReactPopover(elementData.objectId, elementData.reactButton!, ev); + }); + } + + protected _updateReactButton(objectID: number, reactionTypeID: number): void { + this._objects.get(objectID)!.forEach((elementData) => { + if (elementData.reactButton !== null) { + if (reactionTypeID) { + elementData.reactButton.classList.add("active"); + elementData.reactButton.dataset.reactionTypeId = reactionTypeID.toString(); + } else { + elementData.reactButton.dataset.reactionTypeId = "0"; + elementData.reactButton.classList.remove("active"); + } + } + }); + } + + protected _markReactionAsActive(): void { + let reactionTypeID = 0; + this._objects.get(this._popoverCurrentObjectId)!.forEach((element) => { + if (element.reactButton !== null) { + reactionTypeID = ~~element.reactButton.dataset.reactionTypeId!; + } + }); + + if (!reactionTypeID) { + throw new Error("Unable to find react button for current popover."); + } + + // Clear the old active state. + const popover = this._getPopover(); + popover.querySelectorAll(".reactionTypeButton.active").forEach((el) => el.classList.remove("active")); + + const scrollableContainer = popover.querySelector(".reactionPopoverContent") as HTMLElement; + if (reactionTypeID) { + const reactionTypeButton = popover.querySelector( + `.reactionTypeButton[data-reaction-type-id="${reactionTypeID}"]`, + ) as HTMLElement; + reactionTypeButton.classList.add("active"); + + if (~~reactionTypeButton.dataset.isAssignable! === 0) { + DomUtil.show(reactionTypeButton); + } + + this._scrollReactionIntoView(scrollableContainer, reactionTypeButton); + } else { + // The "first" reaction is positioned as close as possible to the toggle button, + // which means that we need to scroll the list to the bottom if the popover is + // displayed above the toggle button. + if (UiScreen.is("screen-xs")) { + if (popover.classList.contains("inverseOrder")) { + scrollableContainer.scrollTop = 0; + } else { + scrollableContainer.scrollTop = scrollableContainer.scrollHeight - scrollableContainer.clientHeight; + } + } + } + } + + protected _scrollReactionIntoView(scrollableContainer: HTMLElement, reactionTypeButton: HTMLElement): void { + // Do not scroll if the button is located in the upper 75%. + if (reactionTypeButton.offsetTop < scrollableContainer.clientHeight * 0.75) { + scrollableContainer.scrollTop = 0; + } else { + // `Element.scrollTop` permits arbitrary values and will always clamp them to + // the maximum possible offset value. We can abuse this behavior by calculating + // the values to place the selected reaction in the center of the popover, + // regardless of the offset being out of range. + scrollableContainer.scrollTop = + reactionTypeButton.offsetTop + reactionTypeButton.clientHeight / 2 - scrollableContainer.clientHeight / 2; + } + } + + /** + * Toggle the visibility of the react popover. + */ + protected _toggleReactPopover(objectId: number, element: HTMLElement, event: MouseEvent): void { + if (event !== null) { + event.preventDefault(); + event.stopPropagation(); + } + + const availableReactions = Object.values(window.REACTION_TYPES); + if (availableReactions.length === 1) { + const reaction = availableReactions[0]; + this._popoverCurrentObjectId = objectId; + + this._react(reaction.reactionTypeID); + } else { + if (this._popoverCurrentObjectId === 0 || this._popoverCurrentObjectId !== objectId) { + this._openReactPopover(objectId, element); + } else { + this._closePopover(); + } + } + } + + /** + * Opens the react popover for a specific react button. + */ + protected _openReactPopover(objectId: number, element: HTMLElement): void { + if (this._popoverCurrentObjectId !== 0) { + this._closePopover(); + } + + this._popoverCurrentObjectId = objectId; + + UiAlignment.set(this._getPopover(), element, { + pointer: true, + horizontal: this._options.isButtonGroupNavigation ? "left" : "center", + vertical: UiScreen.is("screen-xs") ? "bottom" : "top", + }); + + if (this._options.isButtonGroupNavigation) { + element.closest("nav")!.style.setProperty("opacity", "1", ""); + } + + const popover = this._getPopover(); + + // The popover could be rendered below the input field on mobile, in which case + // the "first" button is displayed at the bottom and thus farthest away. Reversing + // the display order will restore the logic by placing the "first" button as close + // to the react button as possible. + const inverseOrder = popover.style.getPropertyValue("bottom") === "auto"; + if (inverseOrder) { + popover.classList.add("inverseOrder"); + } else { + popover.classList.remove("inverseOrder"); + } + + this._markReactionAsActive(); + + this._rebuildOverflowIndicator(); + + popover.classList.remove("forceHide"); + popover.classList.add("active"); + } + + /** + * Returns the react popover element. + */ + protected _getPopover(): HTMLElement { + if (this._popover == null) { + this._popover = document.createElement("div"); + this._popover.className = "reactionPopover forceHide"; + + this._popoverContent = document.createElement("div"); + this._popoverContent.className = "reactionPopoverContent"; + + const popoverContentHTML = document.createElement("ul"); + popoverContentHTML.className = "reactionTypeButtonList"; + + this._getSortedReactionTypes().forEach((reactionType) => { + const reactionTypeItem = document.createElement("li"); + reactionTypeItem.className = "reactionTypeButton jsTooltip"; + reactionTypeItem.dataset.reactionTypeId = reactionType.reactionTypeID.toString(); + reactionTypeItem.dataset.title = reactionType.title; + reactionTypeItem.dataset.isAssignable = reactionType.isAssignable.toString(); + + reactionTypeItem.title = reactionType.title; + + const reactionTypeItemSpan = document.createElement("span"); + reactionTypeItemSpan.className = "reactionTypeButtonTitle"; + reactionTypeItemSpan.innerHTML = reactionType.title; + + reactionTypeItem.innerHTML = reactionType.renderedIcon; + + reactionTypeItem.appendChild(reactionTypeItemSpan); + + reactionTypeItem.addEventListener("click", () => this._react(reactionType.reactionTypeID)); + + if (!reactionType.isAssignable) { + DomUtil.hide(reactionTypeItem); + } + + popoverContentHTML.appendChild(reactionTypeItem); + }); + + this._popoverContent.appendChild(popoverContentHTML); + this._popoverContent.addEventListener("scroll", () => this._rebuildOverflowIndicator(), { passive: true }); + + this._popover.appendChild(this._popoverContent); + + const pointer = document.createElement("span"); + pointer.className = "elementPointer"; + pointer.appendChild(document.createElement("span")); + this._popover.appendChild(pointer); + + document.body.appendChild(this._popover); + + DomChangeListener.trigger(); + } + + return this._popover; + } + + protected _rebuildOverflowIndicator(): void { + const popoverContent = this._popoverContent!; + const hasTopOverflow = popoverContent.scrollTop > 0; + if (hasTopOverflow) { + popoverContent.classList.add("overflowTop"); + } else { + popoverContent.classList.remove("overflowTop"); + } + + const hasBottomOverflow = popoverContent.scrollTop + popoverContent.clientHeight < popoverContent.scrollHeight; + if (hasBottomOverflow) { + popoverContent.classList.add("overflowBottom"); + } else { + popoverContent.classList.remove("overflowBottom"); + } + } + + /** + * Sort the reaction types by the showOrder field. + */ + protected _getSortedReactionTypes(): Reaction[] { + return Object.values(window.REACTION_TYPES).sort((a, b) => a.showOrder - b.showOrder); + } + + /** + * Closes the react popover. + */ + protected _closePopover(): void { + if (this._popoverCurrentObjectId !== 0) { + const popover = this._getPopover(); + popover.classList.remove("active"); + + popover + .querySelectorAll('.reactionTypeButton[data-is-assignable="0"]') + .forEach((el: HTMLElement) => DomUtil.hide(el)); + + if (this._options.isButtonGroupNavigation) { + this._objects.get(this._popoverCurrentObjectId)!.forEach((elementData) => { + elementData.reactButton!.closest("nav")!.style.cssText = ""; + }); + } + + this._popoverCurrentObjectId = 0; + } + } + + /** + * React with the given reactionTypeId on an object. + */ + protected _react(reactionTypeId: number): void { + if (~~this._popoverCurrentObjectId === 0) { + // Double clicking the reaction will cause the first click to go through, but + // causes the second to fail because the overlay is already closing. + return; + } + + this._options.parameters.reactionTypeID = reactionTypeId; + this._options.parameters.data.objectID = this._popoverCurrentObjectId; + this._options.parameters.data.objectType = this._objectType; + + Ajax.api(this, { + parameters: this._options.parameters, + }); + + this._closePopover(); + } + + _ajaxSuccess(data: AjaxResponse): void { + this.countButtons.updateCountButtons(data.returnValues.objectID, data.returnValues.reactions); + + this._updateReactButton(data.returnValues.objectID, data.returnValues.reactionTypeID); + } + + _ajaxSetup(): ReturnType { + return { + data: { + actionName: "react", + className: "\\wcf\\data\\reaction\\ReactionAction", + }, + }; + } +} + +Core.enableLegacyInheritance(UiReactionHandler); + +export = UiReactionHandler;