From: Alexander Ebert Date: Mon, 11 Jan 2021 19:10:50 +0000 (+0100) Subject: Convert `WCF.Message.Quote.Handler` to TypeScript (#3860) X-Git-Tag: 5.4.0_Alpha_1~457 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=f0e58d79452cf7acd1e097eb8e5737704db60a42;p=GitHub%2FWoltLab%2FWCF.git Convert `WCF.Message.Quote.Handler` to TypeScript (#3860) * Convert `WCF.Message.Quote.Handler` to TypeScript * Export the class separately --- diff --git a/wcfsetup/install/files/js/WCF.Message.js b/wcfsetup/install/files/js/WCF.Message.js index aa137f10d5..a317ae4273 100644 --- a/wcfsetup/install/files/js/WCF.Message.js +++ b/wcfsetup/install/files/js/WCF.Message.js @@ -1027,633 +1027,23 @@ WCF.Message.Quote = { }; if (COMPILER_TARGET_DEFAULT) { /** * Handles message quotes. + * + * @deprecated 5.4 Use `WoltLabSuite/Core/Ui/Message/Quote` instead */ WCF.Message.Quote.Handler = Class.extend({ - /** - * active container id - * @var string - */ - _activeContainerID: '', - - /** - * action class name - * @var string - */ - _className: '', - - /** - * list of message containers - * @var object - */ - _containers: {}, - - /** - * container selector - * @var string - */ - _containerSelector: '', - - /** - * 'copy quote' overlay - * @var jQuery - */ - _copyQuote: null, - - /** - * marked message - * @var string - */ - _message: '', - - /** - * message body selector - * @var string - */ - _messageBodySelector: '', - - /** - * object id - * @var {int} - */ - _objectID: 0, - - /** - * object type name - * @var string - */ - _objectType: '', - - /** - * action proxy - * @var WCF.Action.Proxy - */ - _proxy: null, - - /** - * quote manager - * @var WCF.Message.Quote.Manager - */ - _quoteManager: null, - - /** - * @var {?int} - */ - _selectionChangeTimer: null, - - /** - * @var {boolean} - */ - _isMouseDown: false, - - /** - * Initializes the quote handler for given object type. - * - * @param {WCF.Message.Quote.Manager} quoteManager - * @param {string} className - * @param {string} objectType - * @param {string} containerSelector - * @param {string} messageBodySelector - * @param {string} messageContentSelector - * @param {boolean} supportDirectInsert - */ init: function (quoteManager, className, objectType, containerSelector, messageBodySelector, messageContentSelector, supportDirectInsert) { - this._className = className; - if (this._className === '') { - console.debug("[WCF.Message.QuoteManager] Empty class name given, aborting."); - return; - } - - this._objectType = objectType; - if (this._objectType === '') { - console.debug("[WCF.Message.QuoteManager] Empty object type name given, aborting."); - return; - } - - this._containerSelector = containerSelector; - this._message = ''; - this._messageBodySelector = messageBodySelector; - this._objectID = 0; - this._proxy = new WCF.Action.Proxy({ - success: $.proxy(this._success, this) - }); - this._selectionChangeTimer = null; - this._isMouseDown = false; - - this._initContainers(); - - supportDirectInsert = (supportDirectInsert && quoteManager.supportPaste()); - this._initCopyQuote(supportDirectInsert); - - $(document).mouseup($.proxy(this._mouseUp, this)); - document.addEventListener('selectionchange', this._selectionchange.bind(this)); - - // register with quote manager - this._quoteManager = quoteManager; - this._quoteManager.register(this._objectType, this); - - // register with DOMNodeInsertedHandler - WCF.DOMNodeInsertedHandler.addCallback('WCF.Message.Quote.Handler' + objectType.hashCode(), $.proxy(this._initContainers, this)); - - // Prevent the tooltip from being selectable while the touch pointer is being moved. - var tooltip = this._copyQuote[0]; - document.addEventListener("touchstart", function (event) { - if (tooltip.classList.contains("active")) { - var target = event.target; - if (target !== tooltip && !tooltip.contains(target)) { - tooltip.classList.add("touchForceInaccessible"); - - document.addEventListener("touchend", function () { - tooltip.classList.remove("touchForceInaccessible"); - }, {once: true}); - } - } - }, {passive: true}); - }, - - /** - * Initializes message containers. - */ - _initContainers: function () { - var self = this; - $(this._containerSelector).each(function (index, container) { - var $container = $(container); - var $containerID = $container.wcfIdentify(); - - if (!self._containers[$containerID]) { - self._containers[$containerID] = $container; - if ($container.hasClass('jsInvalidQuoteTarget')) { - return true; - } - - if (self._messageBodySelector) { - $container.data('body', $container.find(self._messageBodySelector).data('containerID', $containerID)); - } - - $container.mousedown($.proxy(self._mouseDown, self)); - $container[0].classList.add('jsQuoteMessageContainer'); - - // bind event to quote whole message - self._containers[$containerID].find('.jsQuoteMessage').click($.proxy(self._saveFullQuote, self)); - } + require(["WoltLabSuite/Core/Ui/Message/Quote"], (UiMessageQuote) => { + new UiMessageQuote.default( + quoteManager, + className, + objectType, + containerSelector, + messageBodySelector, + messageContentSelector, + supportDirectInsert, + ); }); }, - - _selectionchange: function () { - if (this._isMouseDown) { - return; - } - - if (this._activeContainerID === '') { - // check if the selection is non-empty and is entirely contained - // inside a single message container that is registered for quoting - var selection = window.getSelection(); - if (selection.rangeCount !== 1 || selection.isCollapsed) { - return; - } - - var range = selection.getRangeAt(0); - var startContainer = elClosest(range.startContainer, '.jsQuoteMessageContainer'); - var endContainer = elClosest(range.endContainer, '.jsQuoteMessageContainer'); - if (startContainer && startContainer === endContainer && !startContainer.classList.contains('jsInvalidQuoteTarget')) { - // Check if the selection is visible, such as text marked inside containers with an - // active overflow handling attached to it. This can be a side effect of the browser - // search which modifies the text selection, but cannot be distinguished from manual - // selections initiated by the user. - var commonAncestor = range.commonAncestorContainer; - if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { - commonAncestor = commonAncestor.parentNode; - } - - var offsetParent = commonAncestor.offsetParent; - if (startContainer.contains(offsetParent)) { - if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { - // The selected text is not visible to the user. - return; - } - } - - this._activeContainerID = startContainer.id; - } - } - - if (this._selectionChangeTimer !== null) { - window.clearTimeout(this._selectionChangeTimer); - } - - this._selectionChangeTimer = window.setTimeout(this._mouseUp.bind(this), 100); - }, - - /** - * Handles mouse down event. - * - * @param {Event} event - */ - _mouseDown: function (event) { - // hide copy quote - this._copyQuote.removeClass('active'); - - this._activeContainerID = (event.currentTarget.classList.contains('jsInvalidQuoteTarget')) ? '' : event.currentTarget.id; - - if (this._selectionChangeTimer !== null) { - window.clearTimeout(this._selectionChangeTimer); - this._selectionChangeTimer = null; - } - - this._isMouseDown = true; - }, - - /** - * Returns the text of a node and its children. - * - * @param {Node} node - * @return {string} - */ - _getNodeText: function (node) { - // work-around for IE, see http://stackoverflow.com/a/5983176 - var $nodeFilter = function (node) { - switch (node.tagName) { - case 'BLOCKQUOTE': - case 'SCRIPT': - return NodeFilter.FILTER_REJECT; - - case 'IMG': - if (!node.classList.contains('smiley') || node.alt.length === 0) { - return NodeFilter.FILTER_REJECT; - } - // fallthrough - - //noinspection FallthroughInSwitchStatementJS - default: - return NodeFilter.FILTER_ACCEPT; - } - }; - $nodeFilter.acceptNode = $nodeFilter; - - var $walker = document.createTreeWalker( - node, - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, - $nodeFilter, - true - ); - - var $text = '', ignoreLinks = [], value; - while ($walker.nextNode()) { - var $node = $walker.currentNode; - - if ($node.nodeType === Node.ELEMENT_NODE) { - switch ($node.tagName) { - case 'A': - // \u2026 === … - value = $node.textContent; - if (value.indexOf('\u2026') > 0) { - var tmp = value.split(/\u2026/); - if (tmp.length === 2) { - var href = $node.href; - if (href.indexOf(tmp[0]) === 0 && href.substr(tmp[1].length * -1) === tmp[1]) { - // truncated url, use original href to preserve link - $text += href; - ignoreLinks.push($node); - } - } - } - break; - - case 'BR': - case 'LI': - case 'UL': - $text += "\n"; - break; - - case 'TD': - if (!$.browser.msie) { - $text += "\n"; - } - break; - - case 'P': - $text += "\n\n"; - break; - - // smilies - case 'IMG': - $text += " " + $node.alt + " "; - break; - - // Code listing - case 'DIV': - if ($node.classList.contains('codeBoxHeadline') || $node.classList.contains('codeBoxLine')) { - $text += "\n"; - } - break; - } - } - else { - if ($node.parentNode.nodeName === 'A' && ignoreLinks.indexOf($node.parentNode) !== -1) { - // ignore text content of links that have already been captured - continue; - } - - // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing - // pointless linebreaks to be inserted. Replacing them with a simple space will - // preserve the spacing between words that would otherwise be lost. - $text += $node.nodeValue.replace(/\n/g, ' '); - } - - } - - return $text; - }, - - /** - * Handles the mouse up event. - * - * @param {?$.Event} event - */ - _mouseUp: function (event) { - if (event && event.originalEvent instanceof Event) { - if (this._selectionChangeTimer !== null) { - // prevent collisions of the `selectionchange` and the `mouseup` event - window.clearTimeout(this._selectionChangeTimer); - this._selectionChangeTimer = null; - } - - this._isMouseDown = false; - } - - // ignore event - if (this._activeContainerID === '') { - this._copyQuote.removeClass('active'); - return; - } - - var selection = window.getSelection(); - if (selection.rangeCount !== 1 || selection.isCollapsed) { - this._copyQuote.removeClass('active'); - return; - } - - var $container = this._containers[this._activeContainerID]; - var $objectID = $container.data('objectID'); - $container = $container.data('body') || $container; - - var anchorNode = selection.anchorNode; - while (anchorNode) { - if (anchorNode === $container[0]) { - break; - } - - anchorNode = anchorNode.parentNode; - } - - // selection spans unrelated nodes - if (anchorNode !== $container[0]) { - this._copyQuote.removeClass('active'); - return; - } - - var $selection = this._getSelectedText(); - var $text = $.trim($selection); - if ($text === '') { - this._copyQuote.removeClass('active'); - - return; - } - - // check if mousedown/mouseup took place inside a blockquote - var range = selection.getRangeAt(0); - var startContainer = (range.startContainer.nodeType === Node.TEXT_NODE) ? range.startContainer.parentNode : range.startContainer; - var endContainer = (range.endContainer.nodeType === Node.TEXT_NODE) ? range.endContainer.parentNode : range.endContainer; - if (startContainer.closest('blockquote') || endContainer.closest('blockquote')) { - this._copyQuote.removeClass('active'); - - return; - } - - // compare selection with message text of given container - var $messageText = this._getNodeText($container[0]); - - // selected text is not part of $messageText or contains text from unrelated nodes - if (this._normalize($messageText).indexOf(this._normalize($text)) === -1) { - return; - } - this._copyQuote.addClass('active'); - - var $coordinates = this._getBoundingRectangle($container, window.getSelection()); - var $dimensions = this._copyQuote.getDimensions('outer'); - var $left = ($coordinates.right - $coordinates.left) / 2 - ($dimensions.width / 2) + $coordinates.left; - - // Prevent the overlay from overflowing the left or right boundary of the container. - var containerBoundaries = $container[0].getBoundingClientRect(); - if ($left < containerBoundaries.left) { - $left = containerBoundaries.left; - } - else if ($left + $dimensions.width > containerBoundaries.right) { - $left = containerBoundaries.right - $dimensions.width; - } - - this._copyQuote.css({ - top: $coordinates.bottom + 7 + 'px', - left: $left + 'px' - }); - this._copyQuote.removeClass('active'); - - if (this._selectionChangeTimer === null) { - // reset containerID - this._activeContainerID = ''; - } - else { - window.clearTimeout(this._selectionChangeTimer); - this._selectionChangeTimer = null; - } - - // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) - var self = this; - window.setTimeout(function () { - var $text = $.trim(self._getSelectedText()); - if ($text !== '') { - self._copyQuote.addClass('active'); - self._message = $text; - self._objectID = $objectID; - } - }, 10); - }, - - /** - * Normalizes a text for comparison. - * - * @param {string} text - * @return {string} - */ - _normalize: function (text) { - return text.replace(/\r?\n|\r/g, "\n").replace(/\s/g, ' ').replace(/\s{2,}/g, ' '); - }, - - /** - * Returns the offsets of the selection's bounding rectangle. - * - * @return {Object} - */ - _getBoundingRectangle: function (container, selection) { - var $coordinates = null; - - if (selection.rangeCount > 0) { - // the coordinates returned by getBoundingClientRect() are relative to the viewport, not the document! - var $rect = selection.getRangeAt(0).getBoundingClientRect(); - - var scrollTop = $(document).scrollTop(); - $coordinates = { - bottom: $rect.bottom + scrollTop, - left: $rect.left, - right: $rect.right, - top: $rect.top + scrollTop - }; - } - - return $coordinates; - }, - - /** - * Initializes the 'copy quote' element. - * - * @param {boolean} supportDirectInsert - */ - _initCopyQuote: function (supportDirectInsert) { - this._copyQuote = $('#quoteManagerCopy'); - if (!this._copyQuote.length) { - this._copyQuote = $('
' + WCF.Language.get('wcf.message.quote.quoteSelected') + '
').appendTo(document.body); - var $storeQuote = this._copyQuote.children('span.jsQuoteManagerStore').click($.proxy(this._saveQuote, this)); - if (supportDirectInsert) { - $('' + WCF.Language.get('wcf.message.quote.quoteAndReply') + '').click($.proxy(this._saveAndInsertQuote, this)).insertAfter($storeQuote); - } - } - }, - - /** - * Returns the text selection. - * - * @return string - */ - _getSelectedText: function () { - var $selection = window.getSelection(); - if ($selection.rangeCount) { - return this._getNodeText($selection.getRangeAt(0).cloneContents()); - } - - return ''; - }, - - /** - * Saves a full quote. - * - * @param {Event} event - */ - _saveFullQuote: function (event) { - event.preventDefault(); - - var $listItem = $(event.currentTarget); - - this._proxy.setOption('data', { - actionName: 'saveFullQuote', - className: this._className, - interfaceName: 'wcf\\data\\IMessageQuoteAction', - objectIDs: [$listItem.data('objectID')] - }); - this._proxy.sendRequest(); - - // mark element as quoted - if ($listItem.data('isQuoted')) { - $listItem.data('isQuoted', false).children('a').removeClass('active'); - } - else { - $listItem.data('isQuoted', true).children('a').addClass('active'); - } - - // close navigation on mobile - var $navigationList = $listItem.parents('.buttonGroupNavigation'); - if ($navigationList.hasClass('jsMobileButtonGroupNavigation')) { - $navigationList.children('.dropdownLabel').trigger('click'); - } - }, - - /** - * Saves a quote. - * - * @param {boolean} renderQuote - */ - _saveQuote: function (renderQuote) { - this._proxy.setOption('data', { - actionName: 'saveQuote', - className: this._className, - interfaceName: 'wcf\\data\\IMessageQuoteAction', - objectIDs: [this._objectID], - parameters: { - message: this._message, - renderQuote: (renderQuote === true) - } - }); - this._proxy.sendRequest(); - - var selection = window.getSelection(); - if (selection.rangeCount) { - selection.removeAllRanges(); - this._copyQuote[0].classList.remove("active"); - } - }, - - /** - * Saves a quote and directly inserts it. - */ - _saveAndInsertQuote: function () { - this._saveQuote(true); - }, - - /** - * Handles successful AJAX requests. - * - * @param {Object} data - */ - _success: function (data) { - if (data.returnValues.count !== undefined) { - if (data.returnValues.fullQuoteMessageIDs !== undefined) { - data.returnValues.fullQuoteObjectIDs = data.returnValues.fullQuoteMessageIDs; - } - - var $fullQuoteObjectIDs = (data.returnValues.fullQuoteObjectIDs !== undefined) ? data.returnValues.fullQuoteObjectIDs : {}; - this._quoteManager.updateCount(data.returnValues.count, $fullQuoteObjectIDs); - } - - switch (data.actionName) { - case 'saveQuote': - case 'saveFullQuote': - if (data.returnValues.renderedQuote) { - WCF.System.Event.fireEvent('com.woltlab.wcf.message.quote', 'insert', { - forceInsert: (data.actionName === 'saveQuote'), - quote: data.returnValues.renderedQuote - }); - } - break; - } - }, - - /** - * Updates the full quote data for all matching objects. - * - * @param array $objectIDs - */ - updateFullQuoteObjectIDs: function (objectIDs) { - for (var $containerID in this._containers) { - this._containers[$containerID].find('.jsQuoteMessage').each(function (index, button) { - // reset all markings - var $button = $(button).data('isQuoted', 0); - $button.children('a').removeClass('active'); - - // mark as active - if (WCF.inArray($button.data('objectID'), objectIDs)) { - $button.data('isQuoted', 1).children('a').addClass('active'); - } - }); - } - } }); /** diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js index a98d5d15b5..7c58937aa8 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js @@ -401,6 +401,22 @@ define(["require", "exports", "tslib", "../StringUtil"], function (require, expo } return innerError; }, + /** + * Finds the closest element that matches the provided selector. This is a helper + * function because `closest()` does exist on elements only, for example, it is + * missing on text nodes. + */ + closest(node, selector) { + const element = node instanceof HTMLElement ? node : node.parentElement; + return element.closest(selector); + }, + /** + * Returns the `node` if it is an element or its parent. This is useful when working + * with the range of a text selection. + */ + getClosestElement(node) { + return node instanceof HTMLElement ? node : node.parentElement; + }, }; // expose on window object for backward compatibility window.bc_wcfDomUtil = DomUtil; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js new file mode 100644 index 0000000000..26a26724a7 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js @@ -0,0 +1,424 @@ +define(["require", "exports", "tslib", "../../Ajax", "../../Core", "../../Event/Handler", "../../Language", "../../Dom/Change/Listener", "../../Dom/Util"], function (require, exports, tslib_1, Ajax, Core, EventHandler, Language, Listener_1, Util_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.UiMessageQuote = void 0; + Ajax = tslib_1.__importStar(Ajax); + Core = tslib_1.__importStar(Core); + EventHandler = tslib_1.__importStar(EventHandler); + Language = tslib_1.__importStar(Language); + Listener_1 = tslib_1.__importDefault(Listener_1); + Util_1 = tslib_1.__importDefault(Util_1); + class UiMessageQuote { + /** + * Initializes the quote handler for given object type. + */ + constructor(quoteManager, // TODO + className, objectType, containerSelector, messageBodySelector, messageContentSelector, supportDirectInsert) { + this.activeMessageId = ""; + this.containers = new Map(); + this.containerSelector = ""; + this.copyQuote = document.createElement("div"); + this.message = ""; + this.objectId = 0; + this.objectType = ""; + this.timerSelectionChange = undefined; + this.isMouseDown = false; + this.className = className; + this.objectType = objectType; + this.containerSelector = containerSelector; + this.messageBodySelector = messageBodySelector; + this.initContainers(); + supportDirectInsert = supportDirectInsert && quoteManager.supportPaste(); + this.quoteManager = quoteManager; + this.initCopyQuote(supportDirectInsert); + document.addEventListener("mouseup", (event) => this.onMouseUp(event)); + document.addEventListener("selectionchange", () => this.onSelectionchange()); + Listener_1.default.add("UiMessageQuote", () => this.initContainers()); + // Prevent the tooltip from being selectable while the touch pointer is being moved. + document.addEventListener("touchstart", (event) => { + if (this.copyQuote.classList.contains("active")) { + const target = event.target; + if (target !== this.copyQuote && !this.copyQuote.contains(target)) { + this.copyQuote.classList.add("touchForceInaccessible"); + document.addEventListener("touchend", () => { + this.copyQuote.classList.remove("touchForceInaccessible"); + }, { once: true }); + } + } + }, { passive: true }); + } + /** + * Initializes message containers. + */ + initContainers() { + document.querySelectorAll(this.containerSelector).forEach((container) => { + var _a; + const id = Util_1.default.identify(container); + if (this.containers.has(id)) { + return; + } + this.containers.set(id, container); + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + container.addEventListener("mousedown", (event) => this.onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + (_a = container + .querySelector(".jsQuoteMessage")) === null || _a === void 0 ? void 0 : _a.addEventListener("click", (event) => this.saveFullQuote(event)); + }); + } + onSelectionchange() { + if (this.isMouseDown) { + return; + } + if (this.activeMessageId === "") { + // check if the selection is non-empty and is entirely contained + // inside a single message container that is registered for quoting + const selection = window.getSelection(); + if (selection.rangeCount !== 1 || selection.isCollapsed) { + return; + } + const range = selection.getRangeAt(0); + const startContainer = Util_1.default.closest(range.startContainer, ".jsQuoteMessageContainer"); + const endContainer = Util_1.default.closest(range.endContainer, ".jsQuoteMessageContainer"); + if (startContainer && + startContainer === endContainer && + !startContainer.classList.contains("jsInvalidQuoteTarget")) { + // Check if the selection is visible, such as text marked inside containers with an + // active overflow handling attached to it. This can be a side effect of the browser + // search which modifies the text selection, but cannot be distinguished from manual + // selections initiated by the user. + let commonAncestor = range.commonAncestorContainer; + if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { + commonAncestor = commonAncestor.parentElement; + } + const offsetParent = commonAncestor.offsetParent; + if (startContainer.contains(offsetParent)) { + if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { + // The selected text is not visible to the user. + return; + } + } + this.activeMessageId = startContainer.id; + } + } + if (this.timerSelectionChange) { + window.clearTimeout(this.timerSelectionChange); + } + this.timerSelectionChange = window.setTimeout(() => this.onMouseUp(), 100); + } + onMouseDown(event) { + // hide copy quote + this.copyQuote.classList.remove("active"); + const message = event.currentTarget; + this.activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; + if (this.timerSelectionChange) { + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + this.isMouseDown = true; + } + /** + * Returns the text of a node and its children. + */ + getNodeText(node) { + const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { + return NodeFilter.FILTER_REJECT; + } + if (node instanceof HTMLImageElement) { + // Skip any image that is not a smiley or contains no alt text. + if (!node.classList.contains("smiley") || !node.alt) { + return NodeFilter.FILTER_REJECT; + } + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + let text = ""; + const ignoreLinks = []; + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode; + if (node instanceof Text) { + const parent = node.parentElement; + if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { + // ignore text content of links that have already been captured + continue; + } + // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing + // pointless linebreaks to be inserted. Replacing them with a simple space will + // preserve the spacing between words that would otherwise be lost. + text += node.nodeValue.replace(/\n/g, " "); + continue; + } + if (node instanceof HTMLAnchorElement) { + // \u2026 === … + const value = node.textContent; + if (value.indexOf("\u2026") > 0) { + const tmp = value.split(/\u2026/); + if (tmp.length === 2) { + const href = node.href; + if (href.indexOf(tmp[0]) === 0 && href.substr(tmp[1].length * -1) === tmp[1]) { + // This is a truncated url, use the original href instead to preserve the link. + text += href; + ignoreLinks.push(node); + } + } + } + } + switch (node.nodeName) { + case "BR": + case "LI": + case "TD": + case "UL": + text += "\n"; + break; + case "P": + text += "\n\n"; + break; + // smilies + case "IMG": { + const img = node; + text += ` ${img.alt} `; + break; + } + // Code listing + case "DIV": + if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { + text += "\n"; + } + break; + } + } + return text; + } + onMouseUp(event) { + if (event instanceof Event) { + if (this.timerSelectionChange) { + // Prevent collisions of the `selectionchange` and the `mouseup` event. + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + this.isMouseDown = false; + } + // ignore event + if (this.activeMessageId === "") { + this.copyQuote.classList.remove("active"); + return; + } + const selection = window.getSelection(); + if (selection.rangeCount !== 1 || selection.isCollapsed) { + this.copyQuote.classList.remove("active"); + return; + } + const container = this.containers.get(this.activeMessageId); + const objectId = ~~container.dataset.objectId; + const content = this.messageBodySelector + ? container.querySelector(this.messageBodySelector) + : container; + let anchorNode = selection.anchorNode; + while (anchorNode) { + if (anchorNode === content) { + break; + } + anchorNode = anchorNode.parentNode; + } + // selection spans unrelated nodes + if (anchorNode !== content) { + this.copyQuote.classList.remove("active"); + return; + } + const selectedText = this.getSelectedText(); + const text = selectedText.trim(); + if (text === "") { + this.copyQuote.classList.remove("active"); + return; + } + // check if mousedown/mouseup took place inside a blockquote + const range = selection.getRangeAt(0); + const startContainer = Util_1.default.getClosestElement(range.startContainer); + const endContainer = Util_1.default.getClosestElement(range.endContainer); + if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { + this.copyQuote.classList.remove("active"); + return; + } + // compare selection with message text of given container + const messageText = this.getNodeText(content); + // selected text is not part of $messageText or contains text from unrelated nodes + if (!this.normalizeTextForComparison(messageText).includes(this.normalizeTextForComparison(text))) { + return; + } + this.copyQuote.classList.add("active"); + const coordinates = this.getElementBoundaries(selection); + const dimensions = { height: this.copyQuote.offsetHeight, width: this.copyQuote.offsetWidth }; + let left = (coordinates.right - coordinates.left) / 2 - dimensions.width / 2 + coordinates.left; + // Prevent the overlay from overflowing the left or right boundary of the container. + const containerBoundaries = content.getBoundingClientRect(); + if (left < containerBoundaries.left) { + left = containerBoundaries.left; + } + else if (left + dimensions.width > containerBoundaries.right) { + left = containerBoundaries.right - dimensions.width; + } + this.copyQuote.style.setProperty("top", `${coordinates.bottom + 7}px`); + this.copyQuote.style.setProperty("left", `${left}px`); + this.copyQuote.classList.remove("active"); + if (!this.timerSelectionChange) { + // reset containerID + this.activeMessageId = ""; + } + else { + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) + window.setTimeout(() => { + const text = this.getSelectedText().trim(); + if (text !== "") { + this.copyQuote.classList.add("active"); + this.message = text; + this.objectId = objectId; + } + }, 10); + } + normalizeTextForComparison(text) { + return text + .replace(/\r?\n|\r/g, "\n") + .replace(/\s/g, " ") + .replace(/\s{2,}/g, " "); + } + getElementBoundaries(selection) { + let coordinates = null; + if (selection.rangeCount > 0) { + // The coordinates returned by getBoundingClientRect() are relative to the + // viewport, not the document. + const rect = selection.getRangeAt(0).getBoundingClientRect(); + const scrollTop = window.pageYOffset; + coordinates = { + bottom: rect.bottom + scrollTop, + left: rect.left, + right: rect.right, + top: rect.top + scrollTop, + }; + } + return coordinates; + } + initCopyQuote(supportDirectInsert) { + const copyQuote = document.getElementById("quoteManagerCopy"); + copyQuote === null || copyQuote === void 0 ? void 0 : copyQuote.remove(); + this.copyQuote.id = "quoteManagerCopy"; + this.copyQuote.classList.add("balloonTooltip", "interactive"); + const buttonSaveQuote = document.createElement("span"); + buttonSaveQuote.classList.add("jsQuoteManagerStore"); + buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected"); + buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event)); + this.copyQuote.appendChild(buttonSaveQuote); + if (supportDirectInsert) { + const buttonSaveAndInsertQuote = document.createElement("span"); + buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); + buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply"); + buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event)); + this.copyQuote.appendChild(buttonSaveAndInsertQuote); + } + document.body.appendChild(this.copyQuote); + } + getSelectedText() { + const selection = window.getSelection(); + if (selection.rangeCount) { + return this.getNodeText(selection.getRangeAt(0).cloneContents()); + } + return ""; + } + saveFullQuote(event) { + event.preventDefault(); + const listItem = event.currentTarget; + Ajax.api(this, { + actionName: "saveFullQuote", + objectIDs: [listItem.dataset.objectId], + }); + // mark element as quoted + const quoteLink = listItem.querySelector("a"); + if (Core.stringToBool(listItem.dataset.isQuoted || "")) { + listItem.dataset.isQuoted = "false"; + quoteLink.classList.remove("active"); + } + else { + listItem.dataset.isQuoted = "true"; + quoteLink.classList.add("active"); + } + // close navigation on mobile + const navigationList = listItem.closest(".buttonGroupNavigation"); + if (navigationList.classList.contains("jsMobileButtonGroupNavigation")) { + const dropDownLabel = navigationList.querySelector(".dropdownLabel"); + dropDownLabel.click(); + } + } + saveQuote(event, renderQuote = false) { + event === null || event === void 0 ? void 0 : event.preventDefault(); + Ajax.api(this, { + actionName: "saveQuote", + objectIDs: [this.objectId], + parameters: { + message: this.message, + renderQuote, + }, + }); + const selection = window.getSelection(); + if (selection.rangeCount) { + selection.removeAllRanges(); + this.copyQuote.classList.remove("active"); + } + } + saveAndInsertQuote(event) { + event.preventDefault(); + this.saveQuote(undefined, true); + } + _ajaxSuccess(data) { + if (data.returnValues.count !== undefined) { + if (data.returnValues.fullQuoteMessageIDs !== undefined) { + data.returnValues.fullQuoteObjectIDs = data.returnValues.fullQuoteMessageIDs; + } + const fullQuoteObjectIDs = data.returnValues.fullQuoteObjectIDs || {}; + this.quoteManager.updateCount(data.returnValues.count, fullQuoteObjectIDs); + } + switch (data.actionName) { + case "saveQuote": + case "saveFullQuote": + if (data.returnValues.renderedQuote) { + EventHandler.fire("com.woltlab.wcf.message.quote", "insert", { + forceInsert: data.actionName === "saveQuote", + quote: data.returnValues.renderedQuote, + }); + } + break; + } + } + _ajaxSetup() { + return { + data: { + className: this.className, + interfaceName: "wcf\\data\\IMessageQuoteAction", + }, + }; + } + /** + * Updates the full quote data for all matching objects. + */ + updateFullQuoteObjectIDs(objectIds) { + this.containers.forEach((message) => { + const quoteButton = message.querySelector(".jsQuoteMessage"); + quoteButton.dataset.isQuoted = "false"; + const quoteButtonLink = quoteButton.querySelector("a"); + quoteButton.classList.remove("active"); + const objectId = ~~quoteButton.dataset.objectID; + if (objectIds.includes(objectId)) { + quoteButton.dataset.isQuoted = "true"; + quoteButtonLink.classList.add("active"); + } + }); + } + } + exports.UiMessageQuote = UiMessageQuote; + exports.default = UiMessageQuote; +}); diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts index 4fad7f9bbe..ae6c35a0a0 100644 --- a/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts @@ -472,6 +472,24 @@ const DomUtil = { return innerError as HTMLElement | null; }, + + /** + * Finds the closest element that matches the provided selector. This is a helper + * function because `closest()` does exist on elements only, for example, it is + * missing on text nodes. + */ + closest(node: Node, selector: string): HTMLElement | null { + const element = node instanceof HTMLElement ? node : node.parentElement!; + return element.closest(selector); + }, + + /** + * Returns the `node` if it is an element or its parent. This is useful when working + * with the range of a text selection. + */ + getClosestElement(node: Node): HTMLElement { + return node instanceof HTMLElement ? node : node.parentElement!; + }, }; interface Dimensions { diff --git a/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts new file mode 100644 index 0000000000..f4c29aaf5c --- /dev/null +++ b/wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts @@ -0,0 +1,553 @@ +import * as Ajax from "../../Ajax"; +import * as Core from "../../Core"; +import * as EventHandler from "../../Event/Handler"; +import * as Language from "../../Language"; +import DomChangeListener from "../../Dom/Change/Listener"; +import DomUtil from "../../Dom/Util"; +import { AjaxCallbackObject, AjaxCallbackSetup } from "../../Ajax/Data"; + +interface AjaxResponse { + actionName: string; + returnValues: { + count?: number; + fullQuoteMessageIDs?: unknown; + fullQuoteObjectIDs?: unknown; + renderedQuote?: string; + }; +} + +interface ElementBoundaries { + bottom: number; + left: number; + right: number; + top: number; +} + +export class UiMessageQuote implements AjaxCallbackObject { + private activeMessageId = ""; + + private readonly className: string; + + private containers = new Map(); + + private containerSelector = ""; + + private readonly copyQuote = document.createElement("div"); + + private message = ""; + + private readonly messageBodySelector: string; + + private objectId = 0; + + private objectType = ""; + + private timerSelectionChange?: number = undefined; + + private isMouseDown = false; + + private readonly quoteManager: any; + + /** + * Initializes the quote handler for given object type. + */ + constructor( + quoteManager: any, // TODO + className: string, + objectType: string, + containerSelector: string, + messageBodySelector: string, + messageContentSelector: string, + supportDirectInsert: boolean, + ) { + this.className = className; + this.objectType = objectType; + this.containerSelector = containerSelector; + this.messageBodySelector = messageBodySelector; + + this.initContainers(); + + supportDirectInsert = supportDirectInsert && quoteManager.supportPaste(); + this.quoteManager = quoteManager; + this.initCopyQuote(supportDirectInsert); + + document.addEventListener("mouseup", (event) => this.onMouseUp(event)); + document.addEventListener("selectionchange", () => this.onSelectionchange()); + + DomChangeListener.add("UiMessageQuote", () => this.initContainers()); + + // Prevent the tooltip from being selectable while the touch pointer is being moved. + document.addEventListener( + "touchstart", + (event) => { + if (this.copyQuote.classList.contains("active")) { + const target = event.target as HTMLElement; + if (target !== this.copyQuote && !this.copyQuote.contains(target)) { + this.copyQuote.classList.add("touchForceInaccessible"); + + document.addEventListener( + "touchend", + () => { + this.copyQuote.classList.remove("touchForceInaccessible"); + }, + { once: true }, + ); + } + } + }, + { passive: true }, + ); + } + + /** + * Initializes message containers. + */ + private initContainers(): void { + document.querySelectorAll(this.containerSelector).forEach((container: HTMLElement) => { + const id = DomUtil.identify(container); + if (this.containers.has(id)) { + return; + } + + this.containers.set(id, container); + if (container.classList.contains("jsInvalidQuoteTarget")) { + return; + } + + container.addEventListener("mousedown", (event) => this.onMouseDown(event)); + container.classList.add("jsQuoteMessageContainer"); + + container + .querySelector(".jsQuoteMessage") + ?.addEventListener("click", (event: MouseEvent) => this.saveFullQuote(event)); + }); + } + + private onSelectionchange(): void { + if (this.isMouseDown) { + return; + } + + if (this.activeMessageId === "") { + // check if the selection is non-empty and is entirely contained + // inside a single message container that is registered for quoting + const selection = window.getSelection()!; + if (selection.rangeCount !== 1 || selection.isCollapsed) { + return; + } + + const range = selection.getRangeAt(0); + const startContainer = DomUtil.closest(range.startContainer, ".jsQuoteMessageContainer"); + const endContainer = DomUtil.closest(range.endContainer, ".jsQuoteMessageContainer"); + if ( + startContainer && + startContainer === endContainer && + !startContainer.classList.contains("jsInvalidQuoteTarget") + ) { + // Check if the selection is visible, such as text marked inside containers with an + // active overflow handling attached to it. This can be a side effect of the browser + // search which modifies the text selection, but cannot be distinguished from manual + // selections initiated by the user. + let commonAncestor = range.commonAncestorContainer as HTMLElement; + if (commonAncestor.nodeType !== Node.ELEMENT_NODE) { + commonAncestor = commonAncestor.parentElement!; + } + + const offsetParent = commonAncestor.offsetParent!; + if (startContainer.contains(offsetParent)) { + if (offsetParent.scrollTop + offsetParent.clientHeight < commonAncestor.offsetTop) { + // The selected text is not visible to the user. + return; + } + } + + this.activeMessageId = startContainer.id; + } + } + + if (this.timerSelectionChange) { + window.clearTimeout(this.timerSelectionChange); + } + + this.timerSelectionChange = window.setTimeout(() => this.onMouseUp(), 100); + } + + private onMouseDown(event: MouseEvent): void { + // hide copy quote + this.copyQuote.classList.remove("active"); + + const message = event.currentTarget as HTMLElement; + this.activeMessageId = message.classList.contains("jsInvalidQuoteTarget") ? "" : message.id; + + if (this.timerSelectionChange) { + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + + this.isMouseDown = true; + } + + /** + * Returns the text of a node and its children. + */ + private getNodeText(node: Node): string { + const treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { + acceptNode(node: Node): number { + if (node.nodeName === "BLOCKQUOTE" || node.nodeName === "SCRIPT") { + return NodeFilter.FILTER_REJECT; + } + + if (node instanceof HTMLImageElement) { + // Skip any image that is not a smiley or contains no alt text. + if (!node.classList.contains("smiley") || !node.alt) { + return NodeFilter.FILTER_REJECT; + } + } + + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let text = ""; + const ignoreLinks: HTMLAnchorElement[] = []; + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode as HTMLElement | Text; + + if (node instanceof Text) { + const parent = node.parentElement!; + if (parent instanceof HTMLAnchorElement && ignoreLinks.includes(parent)) { + // ignore text content of links that have already been captured + continue; + } + + // Firefox loves to arbitrarily wrap pasted text at weird line lengths, causing + // pointless linebreaks to be inserted. Replacing them with a simple space will + // preserve the spacing between words that would otherwise be lost. + text += node.nodeValue!.replace(/\n/g, " "); + + continue; + } + + if (node instanceof HTMLAnchorElement) { + // \u2026 === … + const value = node.textContent!; + if (value.indexOf("\u2026") > 0) { + const tmp = value.split(/\u2026/); + if (tmp.length === 2) { + const href = node.href; + if (href.indexOf(tmp[0]) === 0 && href.substr(tmp[1].length * -1) === tmp[1]) { + // This is a truncated url, use the original href instead to preserve the link. + text += href; + ignoreLinks.push(node); + } + } + } + } + + switch (node.nodeName) { + case "BR": + case "LI": + case "TD": + case "UL": + text += "\n"; + break; + + case "P": + text += "\n\n"; + break; + + // smilies + case "IMG": { + const img = node as HTMLImageElement; + text += ` ${img.alt} `; + break; + } + + // Code listing + case "DIV": + if (node.classList.contains("codeBoxHeadline") || node.classList.contains("codeBoxLine")) { + text += "\n"; + } + break; + } + } + + return text; + } + + private onMouseUp(event?: MouseEvent): void { + if (event instanceof Event) { + if (this.timerSelectionChange) { + // Prevent collisions of the `selectionchange` and the `mouseup` event. + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + + this.isMouseDown = false; + } + + // ignore event + if (this.activeMessageId === "") { + this.copyQuote.classList.remove("active"); + + return; + } + + const selection = window.getSelection()!; + if (selection.rangeCount !== 1 || selection.isCollapsed) { + this.copyQuote.classList.remove("active"); + + return; + } + + const container = this.containers.get(this.activeMessageId)!; + const objectId = ~~container.dataset.objectId!; + const content = this.messageBodySelector + ? (container.querySelector(this.messageBodySelector)! as HTMLElement) + : container; + + let anchorNode = selection.anchorNode; + while (anchorNode) { + if (anchorNode === content) { + break; + } + + anchorNode = anchorNode.parentNode; + } + + // selection spans unrelated nodes + if (anchorNode !== content) { + this.copyQuote.classList.remove("active"); + + return; + } + + const selectedText = this.getSelectedText(); + const text = selectedText.trim(); + if (text === "") { + this.copyQuote.classList.remove("active"); + + return; + } + + // check if mousedown/mouseup took place inside a blockquote + const range = selection.getRangeAt(0); + const startContainer = DomUtil.getClosestElement(range.startContainer); + const endContainer = DomUtil.getClosestElement(range.endContainer); + if (startContainer.closest("blockquote") || endContainer.closest("blockquote")) { + this.copyQuote.classList.remove("active"); + + return; + } + + // compare selection with message text of given container + const messageText = this.getNodeText(content); + + // selected text is not part of $messageText or contains text from unrelated nodes + if (!this.normalizeTextForComparison(messageText).includes(this.normalizeTextForComparison(text))) { + return; + } + + this.copyQuote.classList.add("active"); + + const coordinates = this.getElementBoundaries(selection)!; + const dimensions = { height: this.copyQuote.offsetHeight, width: this.copyQuote.offsetWidth }; + let left = (coordinates.right - coordinates.left) / 2 - dimensions.width / 2 + coordinates.left; + + // Prevent the overlay from overflowing the left or right boundary of the container. + const containerBoundaries = content.getBoundingClientRect(); + if (left < containerBoundaries.left) { + left = containerBoundaries.left; + } else if (left + dimensions.width > containerBoundaries.right) { + left = containerBoundaries.right - dimensions.width; + } + + this.copyQuote.style.setProperty("top", `${coordinates.bottom + 7}px`); + this.copyQuote.style.setProperty("left", `${left}px`); + this.copyQuote.classList.remove("active"); + + if (!this.timerSelectionChange) { + // reset containerID + this.activeMessageId = ""; + } else { + window.clearTimeout(this.timerSelectionChange); + this.timerSelectionChange = undefined; + } + + // show element after a delay, to prevent display if text was unmarked again (clicking into marked text) + window.setTimeout(() => { + const text = this.getSelectedText().trim(); + if (text !== "") { + this.copyQuote.classList.add("active"); + this.message = text; + this.objectId = objectId; + } + }, 10); + } + + private normalizeTextForComparison(text: string): string { + return text + .replace(/\r?\n|\r/g, "\n") + .replace(/\s/g, " ") + .replace(/\s{2,}/g, " "); + } + + private getElementBoundaries(selection: Selection): ElementBoundaries | null { + let coordinates: ElementBoundaries | null = null; + + if (selection.rangeCount > 0) { + // The coordinates returned by getBoundingClientRect() are relative to the + // viewport, not the document. + const rect = selection.getRangeAt(0).getBoundingClientRect(); + + const scrollTop = window.pageYOffset; + coordinates = { + bottom: rect.bottom + scrollTop, + left: rect.left, + right: rect.right, + top: rect.top + scrollTop, + }; + } + + return coordinates; + } + + private initCopyQuote(supportDirectInsert: boolean): void { + const copyQuote = document.getElementById("quoteManagerCopy"); + copyQuote?.remove(); + + this.copyQuote.id = "quoteManagerCopy"; + this.copyQuote.classList.add("balloonTooltip", "interactive"); + + const buttonSaveQuote = document.createElement("span"); + buttonSaveQuote.classList.add("jsQuoteManagerStore"); + buttonSaveQuote.textContent = Language.get("wcf.message.quote.quoteSelected"); + buttonSaveQuote.addEventListener("click", (event) => this.saveQuote(event)); + this.copyQuote.appendChild(buttonSaveQuote); + + if (supportDirectInsert) { + const buttonSaveAndInsertQuote = document.createElement("span"); + buttonSaveAndInsertQuote.classList.add("jsQuoteManagerQuoteAndInsert"); + buttonSaveAndInsertQuote.textContent = Language.get("wcf.message.quote.quoteAndReply"); + buttonSaveAndInsertQuote.addEventListener("click", (event) => this.saveAndInsertQuote(event)); + this.copyQuote.appendChild(buttonSaveAndInsertQuote); + } + + document.body.appendChild(this.copyQuote); + } + + private getSelectedText(): string { + const selection = window.getSelection()!; + if (selection.rangeCount) { + return this.getNodeText(selection.getRangeAt(0).cloneContents()); + } + + return ""; + } + + private saveFullQuote(event: MouseEvent): void { + event.preventDefault(); + + const listItem = event.currentTarget as HTMLElement; + + Ajax.api(this, { + actionName: "saveFullQuote", + objectIDs: [listItem.dataset.objectId], + }); + + // mark element as quoted + const quoteLink = listItem.querySelector("a")!; + if (Core.stringToBool(listItem.dataset.isQuoted || "")) { + listItem.dataset.isQuoted = "false"; + quoteLink.classList.remove("active"); + } else { + listItem.dataset.isQuoted = "true"; + quoteLink.classList.add("active"); + } + + // close navigation on mobile + const navigationList = listItem.closest(".buttonGroupNavigation") as HTMLUListElement; + if (navigationList.classList.contains("jsMobileButtonGroupNavigation")) { + const dropDownLabel = navigationList.querySelector(".dropdownLabel") as HTMLElement; + dropDownLabel.click(); + } + } + + private saveQuote(event?: MouseEvent, renderQuote = false) { + event?.preventDefault(); + + Ajax.api(this, { + actionName: "saveQuote", + objectIDs: [this.objectId], + parameters: { + message: this.message, + renderQuote, + }, + }); + + const selection = window.getSelection()!; + if (selection.rangeCount) { + selection.removeAllRanges(); + this.copyQuote.classList.remove("active"); + } + } + + private saveAndInsertQuote(event: MouseEvent) { + event.preventDefault(); + + this.saveQuote(undefined, true); + } + + _ajaxSuccess(data: AjaxResponse): void { + if (data.returnValues.count !== undefined) { + if (data.returnValues.fullQuoteMessageIDs !== undefined) { + data.returnValues.fullQuoteObjectIDs = data.returnValues.fullQuoteMessageIDs; + } + + const fullQuoteObjectIDs = data.returnValues.fullQuoteObjectIDs || {}; + this.quoteManager.updateCount(data.returnValues.count, fullQuoteObjectIDs); + } + + switch (data.actionName) { + case "saveQuote": + case "saveFullQuote": + if (data.returnValues.renderedQuote) { + EventHandler.fire("com.woltlab.wcf.message.quote", "insert", { + forceInsert: data.actionName === "saveQuote", + quote: data.returnValues.renderedQuote, + }); + } + break; + } + } + + _ajaxSetup(): ReturnType { + return { + data: { + className: this.className, + interfaceName: "wcf\\data\\IMessageQuoteAction", + }, + }; + } + + /** + * Updates the full quote data for all matching objects. + */ + updateFullQuoteObjectIDs(objectIds: number[]): void { + this.containers.forEach((message) => { + const quoteButton = message.querySelector(".jsQuoteMessage") as HTMLLIElement; + quoteButton.dataset.isQuoted = "false"; + + const quoteButtonLink = quoteButton.querySelector("a")!; + quoteButton.classList.remove("active"); + + const objectId = ~~quoteButton.dataset.objectID!; + if (objectIds.includes(objectId)) { + quoteButton.dataset.isQuoted = "true"; + quoteButtonLink.classList.add("active"); + } + }); + } +} + +export default UiMessageQuote;