Fixed calculation of positions in IE11
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Ui / Redactor / Mention.js
index 11cc32de5b9cab5faafeaa59f91fc292ee6738d3..ae8c06301d9d889c7bb739e54531981cf7d886b1 100644 (file)
@@ -1,4 +1,4 @@
-define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, Environment, EventHandler, UiAlignment) {
+define(['Ajax', 'Environment', 'Ui/CloseOverlay'], function(Ajax, Environment, UiCloseOverlay) {
        "use strict";
        
        var _dropdownContainer = null;
@@ -7,7 +7,6 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
        UiRedactorMention.prototype = {
                init: function(redactor) {
                        this._active = false;
-                       this._caret = null;
                        this._dropdownActive = false;
                        this._dropdownMenu = null;
                        this._itemIndex = 0;
@@ -18,6 +17,8 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                        
                        redactor.WoltLabEvent.register('keydown', this._keyDown.bind(this));
                        redactor.WoltLabEvent.register('keyup', this._keyUp.bind(this));
+                       
+                       UiCloseOverlay.add('UiRedactorMention-' + redactor.core.element()[0].id, this._hideDropdown.bind(this));
                },
                
                _keyDown: function(data) {
@@ -45,6 +46,7 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                                        break;
                                
                                default:
+                                       this._hideDropdown();
                                        return;
                                        break;
                        }
@@ -64,8 +66,17 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                                return;
                        }
                        
+                       if (this._dropdownActive) {
+                               data.cancel = true;
+                               
+                               // ignore arrow up/down
+                               if (event.which === 38 || event.which === 40) {
+                                       return;
+                               }
+                       }
+                       
                        var text = this._getTextLineInFrontOfCaret();
-                       if (text.length) {
+                       if (text.length > 0 && text.length < 25) {
                                var match = text.match(/@([^,]{3,})$/);
                                if (match) {
                                        // if mentioning is at text begin or there's a whitespace character
@@ -100,44 +111,61 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                        }
                },
                
-               _setUsername: function(event, item) {
-                       if (event) {
-                               event.preventDefault();
-                               item = event.currentTarget;
+               _getTextLineInFrontOfCaret: function() {
+                       var data = this._selectMention(false);
+                       if (data !== null) {
+                               return data.range.cloneContents().textContent.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim();
                        }
                        
-                       /*if (this._timer !== null) {
-                               this._timer.stop();
-                               this._timer = null;
+                       return '';
+               },
+               
+               _getDropdownMenuPosition: function() {
+                       var data = this._selectMention();
+                       if (data === null) {
+                               return null;
                        }
-                       this._proxy.abortPrevious();*/
                        
-                       var selection = window.getSelection();
+                       this._redactor.selection.save();
                        
-                       // restore caret position
-                       selection.removeAllRanges();
-                       selection.addRange(this._caret);
+                       data.selection.removeAllRanges();
+                       data.selection.addRange(data.range);
                        
-                       var orgRange = selection.getRangeAt(0).cloneRange();
+                       // get the offsets of the bounding box of current text selection
+                       var rect = data.selection.getRangeAt(0).getBoundingClientRect();
+                       var offsets = {
+                               top: Math.round(rect.bottom) + (window.scrollY || window.pageYOffset),
+                               left: Math.round(rect.left) + document.body.scrollLeft
+                       };
                        
-                       // allow redactor to undo this
-                       this._redactor.buffer.set();
+                       if (this._lineHeight === null) {
+                               this._lineHeight = Math.round(rect.bottom - rect.top - (window.scrollY || window.pageYOffset));
+                       }
                        
-                       var startContainer = orgRange.startContainer;
-                       var startOffset = orgRange.startOffset - (this._mentionStart.length + 1);
+                       // restore caret position
+                       this._redactor.selection.restore();
                        
-                       // navigating with the keyboard before hitting enter will cause the text node to be split
-                       if (startOffset < 0) {
-                               startContainer = startContainer.previousSibling;
-                               startOffset = startContainer.length - (this._mentionStart.length + 1) - (orgRange.startOffset - 1);
+                       return offsets;
+               },
+               
+               _setUsername: function(event, item) {
+                       if (event) {
+                               event.preventDefault();
+                               item = event.currentTarget;
                        }
                        
-                       var newRange = document.createRange();
-                       newRange.setStart(startContainer, startOffset);
-                       newRange.setEnd(orgRange.startContainer, orgRange.startOffset);
+                       var data = this._selectMention();
+                       if (data === null) {
+                               this._hideDropdown();
+                               
+                               return;
+                       }
                        
-                       selection.removeAllRanges();
-                       selection.addRange(newRange);
+                       // allow redactor to undo this
+                       this._redactor.buffer.set();
+                       
+                       data.selection.removeAllRanges();
+                       data.selection.addRange(data.range);
                        
                        var range = getSelection().getRangeAt(0);
                        range.deleteContents();
@@ -146,57 +174,169 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                        var text = document.createTextNode('@' + elData(item, 'username') + '\u00A0');
                        range.insertNode(text);
                        
-                       newRange = document.createRange();
-                       newRange.selectNode(text);
-                       newRange.collapse(false);
+                       range = document.createRange();
+                       range.selectNode(text);
+                       range.collapse(false);
                        
-                       selection.removeAllRanges();
-                       selection.addRange(newRange);
-                       
-                       this._redactor.selection.save();
+                       data.selection.removeAllRanges();
+                       data.selection.addRange(range);
                        
                        this._hideDropdown();
                },
                
-               _getTextLineInFrontOfCaret: function() {
-                       /** @var Range range */
-                       var range = window.getSelection().getRangeAt(0);
-                       if (!range.collapsed) {
-                               return '';
+               _selectMention: function (skipCheck) {
+                       var selection = window.getSelection();
+                       if (!selection.rangeCount || !selection.isCollapsed) {
+                               return null;
+                       }
+                       
+                       // check if there is an '@' within the current range
+                       if (selection.anchorNode.textContent.indexOf('@') === -1) {
+                               return null;
                        }
                        
-                       // in Firefox, blurring and refocusing the browser creates separate text nodes
-                       if (Environment.browser() === 'firefox' && range.startContainer.nodeType === Node.TEXT_NODE) {
-                               range.startContainer.parentNode.normalize();
+                       // check if we're inside code or quote blocks
+                       var container = selection.anchorNode, editor = this._redactor.core.editor()[0];
+                       while (container && container !== editor) {
+                               if (['PRE', 'WOLTLAB-QUOTE'].indexOf(container.nodeName) !== -1) {
+                                       return null;
+                               }
+                               
+                               container = container.parentNode;
                        }
                        
-                       var text = range.startContainer.textContent.substr(0, range.startOffset);
-                       
-                       // remove unicode zero-width space and non-breaking space
-                       var textBackup = text;
-                       text = '';
-                       var hadSpace = false;
-                       for (var i = 0; i < textBackup.length; i++) {
-                               var byte = textBackup.charCodeAt(i).toString(16);
-                               if (byte !== '200b' && (!/\s/.test(textBackup[i]) || ((byte === 'a0' || byte === '20') && !hadSpace))) {
-                                       if (byte === 'a0' || byte === '20') {
-                                               hadSpace = true;
+                       var range = selection.getRangeAt(0);
+                       var endContainer = range.startContainer;
+                       var endOffset = range.startOffset;
+                       
+                       // find the appropriate end location
+                       while (endContainer.nodeType === Node.ELEMENT_NODE) {
+                               if (endOffset === 0 && endContainer.childNodes.length === 0) {
+                                       // invalid start location
+                                       return null;
+                               }
+                               
+                               // startOffset for elements will always be after a node index
+                               // or at the very start, which means if there is only text node
+                               // and the caret is after it, startOffset will equal `1`
+                               endContainer = endContainer.childNodes[(endOffset ? endOffset - 1 : 0)];
+                               if (endOffset > 0) {
+                                       if (endContainer.nodeType === Node.TEXT_NODE) {
+                                               endOffset = endContainer.textContent.length;
                                        }
-                                       
-                                       if (textBackup[i] === '@' && i && /\s/.test(textBackup[i - 1])) {
-                                               hadSpace = false;
-                                               text = '';
+                                       else {
+                                               endOffset = endContainer.childNodes.length;
                                        }
+                               }
+                       }
+                       
+                       var startContainer = endContainer;
+                       var startOffset = -1;
+                       while (startContainer !== null) {
+                               if (startContainer.nodeType !== Node.TEXT_NODE) {
+                                       return null;
+                               }
+                               
+                               if (startContainer.textContent.indexOf('@') !== -1) {
+                                       startOffset = startContainer.textContent.lastIndexOf('@');
                                        
-                                       text += textBackup[i];
+                                       break;
                                }
-                               else {
-                                       hadSpace = false;
-                                       text = '';
+                               
+                               startContainer = startContainer.previousSibling;
+                       }
+                       
+                       if (startOffset === -1) {
+                               // there was a non-text node that was in our way
+                               return null;
+                       }
+                       
+                       try {
+                               // mark the entire text, starting from the '@' to the current cursor position
+                               range = document.createRange();
+                               range.setStart(startContainer, startOffset);
+                               range.setEnd(endContainer, endOffset);
+                       }
+                       catch (e) {
+                               window.console.debug(e);
+                               return null;
+                       }
+                       
+                       if (skipCheck === false) {
+                               // check if the `@` occurs at the very start of the container
+                               // or at least has a whitespace in front of it
+                               var text = '';
+                               if (startOffset) {
+                                       text = startContainer.textContent.substr(0, startOffset);
+                               }
+                               
+                               while (startContainer = startContainer.previousSibling) {
+                                       if (startContainer.nodeType === Node.TEXT_NODE) {
+                                               text = startContainer.textContent + text;
+                                       }
+                                       else {
+                                               break;
+                                       }
+                               }
+                               
+                               if (text.replace(/\u200B/g, '').match(/\S$/)) {
+                                       return null;
+                               }
+                       }
+                       else {
+                               // check if new range includes the mention text
+                               if (range.cloneContents().textContent.replace(/\u200B/g, '').replace(/\u00A0/g, '').trim().replace(/^@/, '') !== this._mentionStart) {
+                                       // string mismatch
+                                       return null;
                                }
                        }
                        
-                       return text;
+                       return {
+                               range: range,
+                               selection: selection
+                       };
+               },
+               
+               _updateDropdownPosition: function() {
+                       var offset = this._getDropdownMenuPosition();
+                       if (offset === null) {
+                               this._hideDropdown();
+                               
+                               return;
+                       }
+                       offset.top += 7; // add a little vertical gap
+                       
+                       this._dropdownMenu.style.setProperty('left', offset.left + 'px', '');
+                       this._dropdownMenu.style.setProperty('top', offset.top + 'px', '');
+                       
+                       this._selectItem(0);
+                       
+                       if (offset.top + this._dropdownMenu.offsetHeight + 10 > window.innerHeight + (window.scrollY || window.pageYOffset)) {
+                               this._dropdownMenu.style.setProperty('top', offset.top - this._dropdownMenu.offsetHeight - 2 * this._lineHeight + 7 + 'px', '');
+                       }
+               },
+               
+               _selectItem: function(step) {
+                       // find currently active item
+                       var item = elBySel('.active', this._dropdownMenu);
+                       if (item !== null) {
+                               item.classList.remove('active');
+                       }
+                       
+                       this._itemIndex += step;
+                       if (this._itemIndex === -1) {
+                               this._itemIndex = this._dropdownMenu.childElementCount - 1;
+                       }
+                       else if (this._itemIndex === this._dropdownMenu.childElementCount) {
+                               this._itemIndex = 0;
+                       }
+                       
+                       this._dropdownMenu.children[this._itemIndex].classList.add('active');
+               },
+               
+               _hideDropdown: function() {
+                       if (this._dropdownMenu !== null) this._dropdownMenu.classList.remove('dropdownOpen');
+                       this._dropdownActive = false;
                },
                
                _ajaxSetup: function() {
@@ -242,7 +382,7 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                                
                                listItem = elCreate('li');
                                link = elCreate('a');
-                               link.addEventListener(WCF_CLICK_EVENT, callbackClick);
+                               link.addEventListener('mousedown', callbackClick);
                                link.className = 'box16';
                                link.innerHTML = '<span>' + user.icon + '</span> <span>' + user.label + '</span>';
                                elData(link, 'user-id', user.objectID);
@@ -256,88 +396,6 @@ define(['Ajax', 'Environment', 'EventHandler', 'Ui/Alignment'], function(Ajax, E
                        this._dropdownActive = true;
                        
                        this._updateDropdownPosition();
-               },
-               
-               _getDropdownMenuPosition: function() {
-                       this._redactor.selection.save();
-                       
-                       var selection = window.getSelection();
-                       var orgRange = selection.getRangeAt(0).cloneRange();
-                       
-                       // mark the entire text, starting from the '@' to the current cursor position
-                       var newRange = document.createRange();
-                       newRange.setStart(orgRange.startContainer, orgRange.startOffset - (this._mentionStart.length + 1));
-                       newRange.setEnd(orgRange.startContainer, orgRange.startOffset);
-                       
-                       selection.removeAllRanges();
-                       selection.addRange(newRange);
-                       
-                       // get the offsets of the bounding box of current text selection
-                       var rect = selection.getRangeAt(0).getBoundingClientRect();
-                       var offsets = {
-                               top: Math.round(rect.bottom) + window.scrollY,
-                               left: Math.round(rect.left) + document.body.scrollLeft
-                       };
-                       
-                       if (this._lineHeight === null) {
-                               this._lineHeight = Math.round(rect.bottom - rect.top - window.scrollY);
-                       }
-                       
-                       // restore caret position
-                       this._redactor.selection.restore();
-                       
-                       this._caret = orgRange;
-                       
-                       return offsets;
-               },
-               
-               _updateDropdownPosition: function() {
-                       try {
-                               var offset = this._getDropdownMenuPosition();
-                               offset.top += 7; // add a little vertical gap
-                               
-                               this._dropdownMenu.style.setProperty('left', offset.left + 'px', '');
-                               this._dropdownMenu.style.setProperty('top', offset.top + 'px', '');
-                               
-                               this._selectItem(0);
-                               
-                               if (offset.top + this._dropdownMenu.offsetHeight + 10 > window.innerHeight + window.scrollY) {
-                                       this._dropdownMenu.classList.add('dropdownArrowBottom');
-                                       
-                                       this._dropdownMenu.style.setProperty('top', offset.top - this._dropdownMenu.offsetHeight - 2 * this._lineHeight + 7 + 'px', '');
-                               }
-                               else {
-                                       this._dropdownMenu.classList.remove('dropdownArrowBottom');
-                               }
-                       }
-                       catch (e) {
-                               console.debug(e);
-                               // ignore errors that are caused by pressing enter to
-                               // often in a short period of time
-                       }
-               },
-               
-               _selectItem: function(step) {
-                       // find currently active item
-                       var item = elBySel('.active', this._dropdownMenu);
-                       if (item !== null) {
-                               item.classList.remove('active');
-                       }
-                       
-                       this._itemIndex += step;
-                       if (this._itemIndex === -1) {
-                               this._itemIndex = this._dropdownMenu.childElementCount - 1;
-                       }
-                       else if (this._itemIndex === this._dropdownMenu.childElementCount) {
-                               this._itemIndex = 0;
-                       }
-                       
-                       this._dropdownMenu.children[this._itemIndex].classList.add('active');
-               },
-               
-               _hideDropdown: function() {
-                       if (this._dropdownMenu !== null) this._dropdownMenu.classList.remove('dropdownOpen');
-                       this._dropdownActive = false;
                }
        };