Convert `WCF.Message.Quote.Handler` to TypeScript (#3860)
authorAlexander Ebert <ebert@woltlab.com>
Mon, 11 Jan 2021 19:10:50 +0000 (20:10 +0100)
committerGitHub <noreply@github.com>
Mon, 11 Jan 2021 19:10:50 +0000 (20:10 +0100)
* Convert `WCF.Message.Quote.Handler` to TypeScript

* Export the class separately

wcfsetup/install/files/js/WCF.Message.js
wcfsetup/install/files/js/WoltLabSuite/Core/Dom/Util.js
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Message/Quote.js [new file with mode: 0644]
wcfsetup/install/files/ts/WoltLabSuite/Core/Dom/Util.ts
wcfsetup/install/files/ts/WoltLabSuite/Core/Ui/Message/Quote.ts [new file with mode: 0644]

index aa137f10d5bc7c1d01b213e33dfdbb6b55038008..a317ae4273acb88b73e1c4be8602e484a8f3a41f 100644 (file)
@@ -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 === &hellip;
-                                                       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 = $('<div id="quoteManagerCopy" class="balloonTooltip interactive"><span class="jsQuoteManagerStore">' + WCF.Language.get('wcf.message.quote.quoteSelected') + '</span></div>').appendTo(document.body);
-                               var $storeQuote = this._copyQuote.children('span.jsQuoteManagerStore').click($.proxy(this._saveQuote, this));
-                               if (supportDirectInsert) {
-                                       $('<span class="jsQuoteManagerQuoteAndInsert">' + WCF.Language.get('wcf.message.quote.quoteAndReply') + '</span>').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<integer>                $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');
-                                       }
-                               });
-                       }
-               }
        });
        
        /**
index a98d5d15b52afcb854b19f36e1af496b63a219df..7c58937aa82d9e5dcdca2df00596b3ce95a59ea8 100644 (file)
@@ -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 (file)
index 0000000..26a2672
--- /dev/null
@@ -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 === &hellip;
+                    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;
+});
index 4fad7f9bbeec9cb6609060f3ae79f6b49bdea804..ae6c35a0a0aa548e7daa5403e84ceefdf84e6613 100644 (file)
@@ -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 (file)
index 0000000..f4c29aa
--- /dev/null
@@ -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<string, HTMLElement>();
+
+  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 === &hellip;
+        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<AjaxCallbackSetup> {
+    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;